feat(backend/domain): add quiz question feature with Docker migration support
- 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>
This commit is contained in:
parent
c8c5f7431d
commit
04ad7ef008
15 changed files with 2538 additions and 2 deletions
191
GermanApp/Application/DTOs/QuizQuestionDto.cs
Normal file
191
GermanApp/Application/DTOs/QuizQuestionDto.cs
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
using GermanApp.Domain.Entities;
|
||||||
|
|
||||||
|
namespace GermanApp.Application.DTOs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data Transfer Object for QuizQuestion - used for API responses.
|
||||||
|
/// This is a read-only representation of a QuizQuestion entity.
|
||||||
|
/// </summary>
|
||||||
|
public record QuizQuestionDto(
|
||||||
|
int Id,
|
||||||
|
int LessonId,
|
||||||
|
string LessonTitle,
|
||||||
|
string QuestionText,
|
||||||
|
QuestionType Type,
|
||||||
|
string? CorrectAnswer,
|
||||||
|
int Difficulty,
|
||||||
|
int Order,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime? UpdatedAt,
|
||||||
|
IReadOnlyList<QuizOptionDto> Options);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data Transfer Object for creating a new QuizQuestion.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateQuizQuestionDto(
|
||||||
|
int LessonId,
|
||||||
|
string QuestionText,
|
||||||
|
QuestionType Type,
|
||||||
|
string? CorrectAnswer,
|
||||||
|
int Difficulty,
|
||||||
|
int Order,
|
||||||
|
IReadOnlyList<CreateQuizOptionDto>? Options);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data Transfer Object for updating an existing QuizQuestion.
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateQuizQuestionDto(
|
||||||
|
int LessonId,
|
||||||
|
string QuestionText,
|
||||||
|
QuestionType Type,
|
||||||
|
string? CorrectAnswer,
|
||||||
|
int Difficulty,
|
||||||
|
int Order,
|
||||||
|
bool IsActive,
|
||||||
|
IReadOnlyList<UpdateQuizOptionDto>? Options);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data Transfer Object for QuizOption - used for API responses.
|
||||||
|
/// </summary>
|
||||||
|
public record QuizOptionDto(
|
||||||
|
int Id,
|
||||||
|
int QuizQuestionId,
|
||||||
|
string Text,
|
||||||
|
bool IsCorrect,
|
||||||
|
int Order);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data Transfer Object for creating a new QuizOption.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateQuizOptionDto(
|
||||||
|
string Text,
|
||||||
|
bool IsCorrect,
|
||||||
|
int Order);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data Transfer Object for updating an existing QuizOption.
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateQuizOptionDto(
|
||||||
|
int Id,
|
||||||
|
string Text,
|
||||||
|
bool IsCorrect,
|
||||||
|
int Order);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data Transfer Object for quiz question with user's answer.
|
||||||
|
/// </summary>
|
||||||
|
public record QuizQuestionWithAnswerDto(
|
||||||
|
int QuizQuestionId,
|
||||||
|
int LessonId,
|
||||||
|
string QuestionText,
|
||||||
|
QuestionType Type,
|
||||||
|
IReadOnlyList<QuizOptionDto> Options,
|
||||||
|
string? UserAnswer,
|
||||||
|
bool IsCorrect);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data Transfer Object for submitting quiz answers.
|
||||||
|
/// </summary>
|
||||||
|
public record SubmitQuizAnswersDto(
|
||||||
|
int LessonId,
|
||||||
|
IReadOnlyList<QuizAnswerDto> Answers);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data Transfer Object for a single quiz answer submission.
|
||||||
|
/// </summary>
|
||||||
|
public record QuizAnswerDto(
|
||||||
|
int QuizQuestionId,
|
||||||
|
string? AnswerText,
|
||||||
|
int[]? SelectedOptionIds);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data Transfer Object for quiz result.
|
||||||
|
/// </summary>
|
||||||
|
public record QuizResultDto(
|
||||||
|
int LessonId,
|
||||||
|
int TotalQuestions,
|
||||||
|
int CorrectAnswers,
|
||||||
|
double ScorePercentage,
|
||||||
|
bool Passed,
|
||||||
|
TimeSpan TimeTaken,
|
||||||
|
IReadOnlyList<QuizQuestionResultDto> QuestionResults);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data Transfer Object for individual question result.
|
||||||
|
/// </summary>
|
||||||
|
public record QuizQuestionResultDto(
|
||||||
|
int QuizQuestionId,
|
||||||
|
string QuestionText,
|
||||||
|
QuestionType Type,
|
||||||
|
bool IsCorrect,
|
||||||
|
string? CorrectAnswer,
|
||||||
|
string? UserAnswer);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for mapping between QuizQuestion entity and DTOs.
|
||||||
|
/// </summary>
|
||||||
|
public static class QuizQuestionDtoExtensions
|
||||||
|
{
|
||||||
|
public static QuizQuestionDto ToDto(this QuizQuestion quizQuestion) => new(
|
||||||
|
quizQuestion.Id,
|
||||||
|
quizQuestion.LessonId,
|
||||||
|
quizQuestion.Lesson?.Title ?? "Unknown",
|
||||||
|
quizQuestion.QuestionText,
|
||||||
|
quizQuestion.Type,
|
||||||
|
quizQuestion.CorrectAnswer,
|
||||||
|
quizQuestion.Difficulty,
|
||||||
|
quizQuestion.Order,
|
||||||
|
quizQuestion.IsActive,
|
||||||
|
quizQuestion.CreatedAt,
|
||||||
|
quizQuestion.UpdatedAt,
|
||||||
|
quizQuestion.Options?.OrderBy(o => o.Order).Select(o => o.ToDto()).ToList() ?? new List<QuizOptionDto>());
|
||||||
|
|
||||||
|
public static QuizQuestion ToEntity(this CreateQuizQuestionDto dto) =>
|
||||||
|
QuizQuestion.Create(
|
||||||
|
dto.LessonId,
|
||||||
|
dto.QuestionText,
|
||||||
|
dto.Type,
|
||||||
|
dto.CorrectAnswer ?? string.Empty,
|
||||||
|
dto.Difficulty,
|
||||||
|
dto.Order);
|
||||||
|
|
||||||
|
public static void UpdateFromDto(this QuizQuestion quizQuestion, UpdateQuizQuestionDto dto)
|
||||||
|
{
|
||||||
|
quizQuestion.UpdateLesson(dto.LessonId);
|
||||||
|
quizQuestion.UpdateQuestionText(dto.QuestionText);
|
||||||
|
quizQuestion.UpdateType(dto.Type);
|
||||||
|
quizQuestion.UpdateCorrectAnswer(dto.CorrectAnswer ?? string.Empty);
|
||||||
|
quizQuestion.UpdateDifficulty(dto.Difficulty);
|
||||||
|
quizQuestion.UpdateOrder(dto.Order);
|
||||||
|
|
||||||
|
if (dto.IsActive != quizQuestion.IsActive)
|
||||||
|
{
|
||||||
|
if (dto.IsActive)
|
||||||
|
quizQuestion.Activate();
|
||||||
|
else
|
||||||
|
quizQuestion.Deactivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static QuizOptionDto ToDto(this QuizOption quizOption) => new(
|
||||||
|
quizOption.Id,
|
||||||
|
quizOption.QuizQuestionId,
|
||||||
|
quizOption.Text,
|
||||||
|
quizOption.IsCorrect,
|
||||||
|
quizOption.Order);
|
||||||
|
|
||||||
|
public static QuizOption ToEntity(this CreateQuizOptionDto dto, int quizQuestionId) =>
|
||||||
|
QuizOption.Create(
|
||||||
|
quizQuestionId,
|
||||||
|
dto.Text,
|
||||||
|
dto.IsCorrect,
|
||||||
|
dto.Order);
|
||||||
|
|
||||||
|
public static void UpdateFromDto(this QuizOption quizOption, UpdateQuizOptionDto dto)
|
||||||
|
{
|
||||||
|
quizOption.UpdateText(dto.Text);
|
||||||
|
quizOption.UpdateIsCorrect(dto.IsCorrect);
|
||||||
|
quizOption.UpdateOrder(dto.Order);
|
||||||
|
}
|
||||||
|
}
|
||||||
420
GermanApp/Application/Services/QuizQuestionService.cs
Normal file
420
GermanApp/Application/Services/QuizQuestionService.cs
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
266
GermanApp/Domain/Entities/QuizQuestion.cs
Normal file
266
GermanApp/Domain/Entities/QuizQuestion.cs
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
namespace GermanApp.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a quiz question for a lesson.
|
||||||
|
/// Each question has a type (multiple choice, true/false, fill-in-the-blank, etc.)
|
||||||
|
/// and is associated with a specific lesson.
|
||||||
|
/// </summary>
|
||||||
|
public class QuizQuestion
|
||||||
|
{
|
||||||
|
public int Id { get; private set; }
|
||||||
|
public int LessonId { 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 Difficulty { get; private set; } // 1-5 scale
|
||||||
|
public int Order { get; private set; }
|
||||||
|
public bool IsActive { 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<QuizOption> Options { get; private set; } = new List<QuizOption>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructor for EF Core deserialization.
|
||||||
|
/// </summary>
|
||||||
|
private QuizQuestion() { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory method to create a new quiz question.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="lessonId">ID of the parent lesson</param>
|
||||||
|
/// <param name="questionText">The question text</param>
|
||||||
|
/// <param name="type">The question type</param>
|
||||||
|
/// <param name="correctAnswer">The correct answer</param>
|
||||||
|
/// <param name="difficulty">Difficulty level (1-5)</param>
|
||||||
|
/// <param name="order">Sort order within the lesson</param>
|
||||||
|
public static QuizQuestion Create(
|
||||||
|
int lessonId,
|
||||||
|
string questionText,
|
||||||
|
QuestionType type,
|
||||||
|
string correctAnswer,
|
||||||
|
int difficulty = 3,
|
||||||
|
int order = 1)
|
||||||
|
{
|
||||||
|
if (difficulty < 1 || difficulty > 5)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(difficulty), "Difficulty must be between 1 and 5");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(questionText))
|
||||||
|
throw new ArgumentException("Question text cannot be empty", nameof(questionText));
|
||||||
|
|
||||||
|
return new QuizQuestion
|
||||||
|
{
|
||||||
|
LessonId = lessonId,
|
||||||
|
QuestionText = questionText,
|
||||||
|
Type = type,
|
||||||
|
CorrectAnswer = correctAnswer,
|
||||||
|
Difficulty = difficulty,
|
||||||
|
Order = order,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the question text.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateQuestionText(string newQuestionText)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(newQuestionText))
|
||||||
|
throw new ArgumentException("Question text cannot be empty", nameof(newQuestionText));
|
||||||
|
|
||||||
|
QuestionText = newQuestionText;
|
||||||
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the correct answer.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateCorrectAnswer(string newCorrectAnswer)
|
||||||
|
{
|
||||||
|
CorrectAnswer = newCorrectAnswer;
|
||||||
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the difficulty level.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateDifficulty(int newDifficulty)
|
||||||
|
{
|
||||||
|
if (newDifficulty < 1 || newDifficulty > 5)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(newDifficulty), "Difficulty must be between 1 and 5");
|
||||||
|
|
||||||
|
Difficulty = newDifficulty;
|
||||||
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the sort order.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateOrder(int newOrder)
|
||||||
|
{
|
||||||
|
Order = newOrder;
|
||||||
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the question type.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateType(QuestionType newType)
|
||||||
|
{
|
||||||
|
Type = newType;
|
||||||
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the associated lesson.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateLesson(int newLessonId)
|
||||||
|
{
|
||||||
|
LessonId = newLessonId;
|
||||||
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Activates the quiz question.
|
||||||
|
/// </summary>
|
||||||
|
public void Activate()
|
||||||
|
{
|
||||||
|
IsActive = true;
|
||||||
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deactivates the quiz question.
|
||||||
|
/// </summary>
|
||||||
|
public void Deactivate()
|
||||||
|
{
|
||||||
|
IsActive = false;
|
||||||
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds an option to this question.
|
||||||
|
/// </summary>
|
||||||
|
public void AddOption(QuizOption option)
|
||||||
|
{
|
||||||
|
Options.Add(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes an option from this question.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveOption(QuizOption option)
|
||||||
|
{
|
||||||
|
Options.Remove(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an option for a multiple-choice quiz question.
|
||||||
|
/// </summary>
|
||||||
|
public class QuizOption
|
||||||
|
{
|
||||||
|
public int Id { get; private set; }
|
||||||
|
public int QuizQuestionId { get; private set; }
|
||||||
|
public string Text { get; private set; } = string.Empty;
|
||||||
|
public bool IsCorrect { get; private set; }
|
||||||
|
public int Order { get; private set; }
|
||||||
|
|
||||||
|
// Navigation property
|
||||||
|
public virtual QuizQuestion? QuizQuestion { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructor for EF Core deserialization.
|
||||||
|
/// </summary>
|
||||||
|
private QuizOption() { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory method to create a new quiz option.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="quizQuestionId">ID of the parent quiz question</param>
|
||||||
|
/// <param name="text">The option text</param>
|
||||||
|
/// <param name="isCorrect">Whether this is the correct answer</param>
|
||||||
|
/// <param name="order">Sort order within the question's options</param>
|
||||||
|
public static QuizOption Create(
|
||||||
|
int quizQuestionId,
|
||||||
|
string text,
|
||||||
|
bool isCorrect = false,
|
||||||
|
int order = 1)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
throw new ArgumentException("Option text cannot be empty", nameof(text));
|
||||||
|
|
||||||
|
return new QuizOption
|
||||||
|
{
|
||||||
|
QuizQuestionId = quizQuestionId,
|
||||||
|
Text = text,
|
||||||
|
IsCorrect = isCorrect,
|
||||||
|
Order = order
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the option text.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateText(string newText)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(newText))
|
||||||
|
throw new ArgumentException("Option text cannot be empty", nameof(newText));
|
||||||
|
|
||||||
|
Text = newText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates whether this option is correct.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateIsCorrect(bool newIsCorrect)
|
||||||
|
{
|
||||||
|
IsCorrect = newIsCorrect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the sort order.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateOrder(int newOrder)
|
||||||
|
{
|
||||||
|
Order = newOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enum representing the types of quiz questions.
|
||||||
|
/// </summary>
|
||||||
|
public enum QuestionType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Multiple choice with single correct answer
|
||||||
|
/// </summary>
|
||||||
|
MultipleChoice = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Multiple choice with multiple correct answers
|
||||||
|
/// </summary>
|
||||||
|
MultipleSelect = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True or false question
|
||||||
|
/// </summary>
|
||||||
|
TrueFalse = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fill-in-the-blank question
|
||||||
|
/// </summary>
|
||||||
|
FillInTheBlank = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matching question
|
||||||
|
/// </summary>
|
||||||
|
Matching = 4,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Short answer question
|
||||||
|
/// </summary>
|
||||||
|
ShortAnswer = 5
|
||||||
|
}
|
||||||
|
|
@ -133,3 +133,75 @@ public interface IUserProgressRepository : IRepository<UserProgress, int>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default);
|
Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository interface for QuizQuestion entities.
|
||||||
|
/// </summary>
|
||||||
|
public interface IQuizQuestionRepository : IRepository<QuizQuestion, int>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets quiz questions by lesson ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<QuizQuestion>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all quiz questions ordered by lesson and question order.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<QuizQuestion>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets active quiz questions by lesson ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<QuizQuestion>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a random set of quiz questions for a lesson.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets quiz questions by difficulty level.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<QuizQuestion>> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets quiz questions by type.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<QuizQuestion>> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a quiz question with the given order exists in a lesson.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository interface for QuizOption entities.
|
||||||
|
/// </summary>
|
||||||
|
public interface IQuizOptionRepository : IRepository<QuizOption, int>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets quiz options by question ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<QuizOption>> GetByQuestionAsync(int questionId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the correct option for a question.
|
||||||
|
/// </summary>
|
||||||
|
Task<QuizOption?> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all correct options for a question (for MultipleSelect type).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<QuizOption>> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes all options for a question.
|
||||||
|
/// </summary>
|
||||||
|
Task DeleteByQuestionAsync(int questionId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the order exists for a question.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> OrderExistsForQuestionAsync(int questionId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
||||||
public DbSet<User> Users { get; set; } = null!;
|
public DbSet<User> Users { get; set; } = null!;
|
||||||
public DbSet<UserProgress> UserProgress { get; set; } = null!;
|
public DbSet<UserProgress> UserProgress { get; set; } = null!;
|
||||||
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
|
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
|
||||||
|
public DbSet<QuizQuestion> QuizQuestions { get; set; } = null!;
|
||||||
|
public DbSet<QuizOption> QuizOptions { get; set; } = null!;
|
||||||
|
|
||||||
// Note: Value objects are not stored directly as entities.
|
// Note: Value objects are not stored directly as entities.
|
||||||
// They are owned by entities and stored as part of the entity's data.
|
// They are owned by entities and stored as part of the entity's data.
|
||||||
|
|
@ -132,6 +134,55 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Configure QuizQuestion entity
|
||||||
|
modelBuilder.Entity<QuizQuestion>(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.IsActive).HasDefaultValue(true);
|
||||||
|
builder.Property(q => q.CreatedAt).IsRequired();
|
||||||
|
builder.Property(q => q.UpdatedAt).IsRequired(false);
|
||||||
|
|
||||||
|
// Foreign key to Lesson
|
||||||
|
builder.HasOne(q => q.Lesson)
|
||||||
|
.WithMany()
|
||||||
|
.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 options
|
||||||
|
builder.HasMany(q => q.Options)
|
||||||
|
.WithOne(o => o.QuizQuestion)
|
||||||
|
.HasForeignKey(o => o.QuizQuestionId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure QuizOption entity
|
||||||
|
modelBuilder.Entity<QuizOption>(builder =>
|
||||||
|
{
|
||||||
|
builder.HasKey(o => o.Id);
|
||||||
|
builder.Property(o => o.QuizQuestionId).IsRequired();
|
||||||
|
builder.Property(o => o.Text).IsRequired().HasMaxLength(1000);
|
||||||
|
builder.Property(o => o.IsCorrect).IsRequired().HasDefaultValue(false);
|
||||||
|
builder.Property(o => o.Order).IsRequired().HasDefaultValue(1);
|
||||||
|
|
||||||
|
// Foreign key to QuizQuestion
|
||||||
|
builder.HasOne(o => o.QuizQuestion)
|
||||||
|
.WithMany(q => q.Options)
|
||||||
|
.HasForeignKey(o => o.QuizQuestionId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// Unique constraint: one option per question per order
|
||||||
|
builder.HasIndex(o => new { o.QuizQuestionId, o.Order }).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
// Seed data (optional) - Note: For EF Core, we need to set properties directly
|
// Seed data (optional) - Note: For EF Core, we need to set properties directly
|
||||||
// In a real application, use migrations or a separate seeding mechanism
|
// In a real application, use migrations or a separate seeding mechanism
|
||||||
// modelBuilder.Entity<Lesson>().HasData(
|
// modelBuilder.Entity<Lesson>().HasData(
|
||||||
|
|
|
||||||
390
GermanApp/Infrastructure/Data/Migrations/20260612050652_AddQuizQuestionTables.Designer.cs
generated
Normal file
390
GermanApp/Infrastructure/Data/Migrations/20260612050652_AddQuizQuestionTables.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using GermanApp.Infrastructure.Data.DbContext;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GermanApp.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260612050652_AddQuizQuestionTables")]
|
||||||
|
partial class AddQuizQuestionTables
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.0")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<int>("LevelId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LevelId", "Order")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Lessons");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Order")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Levels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("IsCorrect")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<int>("QuizQuestionId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Text")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("QuizQuestionId", "Order")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("QuizOptions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("CorrectAnswer")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Difficulty")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(3);
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<int>("LessonId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<string>("QuestionText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LessonId", "Order")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("QuizQuestions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CurrentLevel")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)")
|
||||||
|
.HasDefaultValue("A1");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<int>("Streak")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<int>("TotalPoints")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("IsCompleted")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastAttemptDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("LessonId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("QuizScore")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LessonId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "LessonId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("UserProgress");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LevelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Level");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
|
||||||
|
.WithMany("Options")
|
||||||
|
.HasForeignKey("QuizQuestionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("QuizQuestion");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LessonId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Lesson");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GermanApp.Domain.Entities.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LessonId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Lesson");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Options");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GermanApp.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddQuizQuestionTables : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "QuizQuestions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
LessonId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
QuestionText = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
|
||||||
|
Type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CorrectAnswer = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
Difficulty = table.Column<int>(type: "integer", nullable: false, defaultValue: 3),
|
||||||
|
Order = table.Column<int>(type: "integer", nullable: false, defaultValue: 1),
|
||||||
|
IsActive = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_QuizQuestions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_QuizQuestions_Lessons_LessonId",
|
||||||
|
column: x => x.LessonId,
|
||||||
|
principalTable: "Lessons",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "QuizOptions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
QuizQuestionId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Text = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
|
||||||
|
IsCorrect = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
Order = table.Column<int>(type: "integer", nullable: false, defaultValue: 1)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_QuizOptions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_QuizOptions_QuizQuestions_QuizQuestionId",
|
||||||
|
column: x => x.QuizQuestionId,
|
||||||
|
principalTable: "QuizQuestions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_QuizOptions_QuizQuestionId_Order",
|
||||||
|
table: "QuizOptions",
|
||||||
|
columns: new[] { "QuizQuestionId", "Order" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_QuizQuestions_LessonId_Order",
|
||||||
|
table: "QuizQuestions",
|
||||||
|
columns: new[] { "LessonId", "Order" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "QuizOptions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "QuizQuestions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -102,6 +102,92 @@ namespace GermanApp.Migrations
|
||||||
b.ToTable("Levels");
|
b.ToTable("Levels");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("IsCorrect")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<int>("QuizQuestionId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Text")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("QuizQuestionId", "Order")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("QuizOptions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("CorrectAnswer")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Difficulty")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(3);
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<int>("LessonId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<string>("QuestionText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LessonId", "Order")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("QuizQuestions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -241,6 +327,28 @@ namespace GermanApp.Migrations
|
||||||
b.Navigation("Level");
|
b.Navigation("Level");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
|
||||||
|
.WithMany("Options")
|
||||||
|
.HasForeignKey("QuizQuestionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("QuizQuestion");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LessonId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Lesson");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
b.HasOne("GermanApp.Domain.Entities.User", null)
|
||||||
|
|
@ -268,6 +376,11 @@ namespace GermanApp.Migrations
|
||||||
|
|
||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Options");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
using GermanApp.Domain.Entities;
|
||||||
|
using GermanApp.Domain.Interfaces;
|
||||||
|
using GermanApp.Infrastructure.Data.DbContext;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GermanApp.Infrastructure.Data.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entity Framework Core implementation of IQuizOptionRepository.
|
||||||
|
/// This is part of the Infrastructure layer.
|
||||||
|
/// </summary>
|
||||||
|
public class QuizOptionRepository : IQuizOptionRepository
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _context;
|
||||||
|
|
||||||
|
public QuizOptionRepository(AppDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuizOption?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizOptions
|
||||||
|
.Include(o => o.QuizQuestion)
|
||||||
|
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<QuizOption>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizOptions
|
||||||
|
.Include(o => o.QuizQuestion)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuizOption> AddAsync(QuizOption entity, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _context.QuizOptions.AddAsync(entity, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(QuizOption entity, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_context.QuizOptions.Update(entity);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(QuizOption entity, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_context.QuizOptions.Remove(entity);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizOptions
|
||||||
|
.AnyAsync(o => o.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<QuizOption>> GetByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizOptions
|
||||||
|
.Include(o => o.QuizQuestion)
|
||||||
|
.Where(o => o.QuizQuestionId == questionId)
|
||||||
|
.OrderBy(o => o.Order)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuizOption?> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizOptions
|
||||||
|
.Include(o => o.QuizQuestion)
|
||||||
|
.Where(o => o.QuizQuestionId == questionId && o.IsCorrect)
|
||||||
|
.OrderBy(o => o.Order)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<QuizOption>> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizOptions
|
||||||
|
.Include(o => o.QuizQuestion)
|
||||||
|
.Where(o => o.QuizQuestionId == questionId && o.IsCorrect)
|
||||||
|
.OrderBy(o => o.Order)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var options = await _context.QuizOptions
|
||||||
|
.Where(o => o.QuizQuestionId == questionId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
_context.QuizOptions.RemoveRange(options);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> OrderExistsForQuestionAsync(int questionId, int order, int? excludeId = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = _context.QuizOptions
|
||||||
|
.Where(o => o.QuizQuestionId == questionId && o.Order == order);
|
||||||
|
|
||||||
|
if (excludeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(o => o.Id != excludeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.AnyAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
using GermanApp.Domain.Entities;
|
||||||
|
using GermanApp.Domain.Interfaces;
|
||||||
|
using GermanApp.Infrastructure.Data.DbContext;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GermanApp.Infrastructure.Data.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entity Framework Core implementation of IQuizQuestionRepository.
|
||||||
|
/// This is part of the Infrastructure layer.
|
||||||
|
/// </summary>
|
||||||
|
public class QuizQuestionRepository : IQuizQuestionRepository
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _context;
|
||||||
|
|
||||||
|
public QuizQuestionRepository(AppDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuizQuestion?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizQuestions
|
||||||
|
.Include(q => q.Lesson)
|
||||||
|
.Include(q => q.Options.OrderBy(o => o.Order))
|
||||||
|
.FirstOrDefaultAsync(q => q.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<QuizQuestion>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizQuestions
|
||||||
|
.Include(q => q.Lesson)
|
||||||
|
.Include(q => q.Options)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuizQuestion> AddAsync(QuizQuestion entity, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _context.QuizQuestions.AddAsync(entity, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(QuizQuestion entity, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_context.QuizQuestions.Update(entity);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(QuizQuestion entity, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_context.QuizQuestions.Remove(entity);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizQuestions
|
||||||
|
.AnyAsync(q => q.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<QuizQuestion>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizQuestions
|
||||||
|
.Include(q => q.Lesson)
|
||||||
|
.Include(q => q.Options.OrderBy(o => o.Order))
|
||||||
|
.Where(q => q.LessonId == lessonId)
|
||||||
|
.OrderBy(q => q.Order)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<QuizQuestion>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizQuestions
|
||||||
|
.Include(q => q.Lesson)
|
||||||
|
.Include(q => q.Options.OrderBy(o => o.Order))
|
||||||
|
.OrderBy(q => q.Lesson.Order)
|
||||||
|
.ThenBy(q => q.Order)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<QuizQuestion>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizQuestions
|
||||||
|
.Include(q => q.Lesson)
|
||||||
|
.Include(q => q.Options.OrderBy(o => o.Order))
|
||||||
|
.Where(q => q.LessonId == lessonId && q.IsActive)
|
||||||
|
.OrderBy(q => q.Order)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizQuestions
|
||||||
|
.Include(q => q.Lesson)
|
||||||
|
.Include(q => q.Options.OrderBy(o => o.Order))
|
||||||
|
.Where(q => q.LessonId == lessonId && q.IsActive)
|
||||||
|
.OrderBy(q => Guid.NewGuid()) // Random ordering
|
||||||
|
.Take(count)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<QuizQuestion>> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizQuestions
|
||||||
|
.Include(q => q.Lesson)
|
||||||
|
.Include(q => q.Options)
|
||||||
|
.Where(q => q.Difficulty == difficulty)
|
||||||
|
.OrderBy(q => q.Order)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<QuizQuestion>> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.QuizQuestions
|
||||||
|
.Include(q => q.Lesson)
|
||||||
|
.Include(q => q.Options)
|
||||||
|
.Where(q => q.Type == type)
|
||||||
|
.OrderBy(q => q.Order)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = _context.QuizQuestions
|
||||||
|
.Where(q => q.LessonId == lessonId && q.Order == order);
|
||||||
|
|
||||||
|
if (excludeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(q => q.Id != excludeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.AnyAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,7 +66,47 @@ public static class SeedDataExtension
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
Console.WriteLine("Database seeded with levels, admin user, test user, and sample lessons.");
|
// Seed quiz questions for the lessons
|
||||||
|
dbContext.QuizQuestions.AddRange(new[]
|
||||||
|
{
|
||||||
|
// Questions for Greetings lesson (Lesson 1)
|
||||||
|
QuizQuestion.Create(1, "What is the German word for 'Hello'?", QuestionType.FillInTheBlank, "Hallo", 1, 1),
|
||||||
|
QuizQuestion.Create(1, "How do you say 'Good morning' in German?", QuestionType.FillInTheBlank, "Guten Morgen", 2, 2),
|
||||||
|
QuizQuestion.Create(1, "What is the German word for 'Goodbye'?", QuestionType.MultipleChoice, "Auf Wiedersehen", 1, 3),
|
||||||
|
// Questions for Numbers lesson (Lesson 2)
|
||||||
|
QuizQuestion.Create(2, "What is '1' in German?", QuestionType.FillInTheBlank, "eins", 1, 1),
|
||||||
|
QuizQuestion.Create(2, "What is '10' in German?", QuestionType.FillInTheBlank, "zehn", 2, 2),
|
||||||
|
QuizQuestion.Create(2, "Which number is 'zwanzig'?", QuestionType.MultipleChoice, "20", 2, 3),
|
||||||
|
// Questions for Grammar Basics lesson (Lesson 3)
|
||||||
|
QuizQuestion.Create(3, "Is 'Der Mann' masculine?", QuestionType.TrueFalse, "True", 3, 1),
|
||||||
|
QuizQuestion.Create(3, "What is the article for 'Frau'?", QuestionType.MultipleChoice, "die", 2, 2)
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Get the quiz question IDs after save
|
||||||
|
var quizQuestions = await dbContext.QuizQuestions.OrderBy(q => q.Id).ToListAsync();
|
||||||
|
|
||||||
|
// Add options for multiple choice questions
|
||||||
|
// greetingsQ3 is ID 3 (3rd question created)
|
||||||
|
dbContext.QuizOptions.AddRange(new[]
|
||||||
|
{
|
||||||
|
QuizOption.Create(quizQuestions[2].Id, "Hallo", false, 1),
|
||||||
|
QuizOption.Create(quizQuestions[2].Id, "Danke", false, 2),
|
||||||
|
QuizOption.Create(quizQuestions[2].Id, "Auf Wiedersehen", true, 3),
|
||||||
|
QuizOption.Create(quizQuestions[2].Id, "Bitte", false, 4),
|
||||||
|
// Numbers Q3 is ID 6
|
||||||
|
QuizOption.Create(quizQuestions[5].Id, "10", false, 1),
|
||||||
|
QuizOption.Create(quizQuestions[5].Id, "20", true, 2),
|
||||||
|
QuizOption.Create(quizQuestions[5].Id, "30", false, 3),
|
||||||
|
QuizOption.Create(quizQuestions[5].Id, "100", false, 4),
|
||||||
|
// Grammar Q2 is ID 8
|
||||||
|
QuizOption.Create(quizQuestions[7].Id, "der", false, 1),
|
||||||
|
QuizOption.Create(quizQuestions[7].Id, "die", true, 2),
|
||||||
|
QuizOption.Create(quizQuestions[7].Id, "das", false, 3)
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
Console.WriteLine("Database seeded with levels, admin user, test user, sample lessons, and quiz questions.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
287
GermanApp/Presentation/Controllers/QuizQuestionsController.cs
Normal file
287
GermanApp/Presentation/Controllers/QuizQuestionsController.cs
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
using GermanApp.Application.DTOs;
|
||||||
|
using GermanApp.Application.Services;
|
||||||
|
using GermanApp.Domain.Entities;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace GermanApp.Presentation.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API controller for managing quiz questions.
|
||||||
|
/// This is part of the Presentation layer.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
public class QuizQuestionsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly QuizQuestionService _quizQuestionService;
|
||||||
|
|
||||||
|
public QuizQuestionsController(QuizQuestionService quizQuestionService)
|
||||||
|
{
|
||||||
|
_quizQuestionService = quizQuestionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all quiz questions.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>List of all quiz questions</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var questions = await _quizQuestionService.GetAllQuizQuestionsAsync(cancellationToken);
|
||||||
|
return Ok(questions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all active quiz questions for a specific lesson.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
|
/// <returns>List of quiz questions for the specified lesson</returns>
|
||||||
|
[HttpGet("by-lesson/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var questions = await _quizQuestionService.GetActiveQuizQuestionsByLessonAsync(lessonId, cancellationToken);
|
||||||
|
return Ok(questions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a specific quiz question by its ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The quiz question ID</param>
|
||||||
|
/// <returns>The quiz question with the specified ID</returns>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var question = await _quizQuestionService.GetQuizQuestionByIdAsync(id, cancellationToken);
|
||||||
|
if (question == null)
|
||||||
|
return NotFound();
|
||||||
|
return Ok(question);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a random set of quiz questions for a lesson.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
|
/// <param name="count">Number of questions to return</param>
|
||||||
|
/// <returns>List of random quiz questions for the lesson</returns>
|
||||||
|
[HttpGet("random/{lessonId}/{count}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var questions = await _quizQuestionService.GetRandomQuizQuestionsForLessonAsync(lessonId, count, cancellationToken);
|
||||||
|
return Ok(questions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets quiz questions by difficulty level.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="difficulty">The difficulty level (1-5)</param>
|
||||||
|
/// <returns>List of quiz questions with the specified difficulty</returns>
|
||||||
|
[HttpGet("by-difficulty/{difficulty}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var questions = await _quizQuestionService.GetQuizQuestionsByDifficultyAsync(difficulty, cancellationToken);
|
||||||
|
return Ok(questions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets quiz questions by type.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The question type</param>
|
||||||
|
/// <returns>List of quiz questions with the specified type</returns>
|
||||||
|
[HttpGet("by-type/{type}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var questions = await _quizQuestionService.GetQuizQuestionsByTypeAsync(type, cancellationToken);
|
||||||
|
return Ok(questions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new quiz question.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto">The quiz question data</param>
|
||||||
|
/// <returns>The created quiz question</returns>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public async Task<IActionResult> CreateQuizQuestionAsync(CreateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var question = await _quizQuestionService.CreateQuizQuestionAsync(dto, cancellationToken);
|
||||||
|
return CreatedAtAction(nameof(GetQuizQuestionByIdAsync), new { id = question.Id }, question);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing quiz question.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The quiz question ID</param>
|
||||||
|
/// <param name="dto">The updated quiz question data</param>
|
||||||
|
/// <returns>The updated quiz question</returns>
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public async Task<IActionResult> UpdateQuizQuestionAsync(int id, UpdateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var question = await _quizQuestionService.UpdateQuizQuestionAsync(id, dto, cancellationToken);
|
||||||
|
if (question == null)
|
||||||
|
return NotFound();
|
||||||
|
return Ok(question);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a quiz question by its ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The quiz question ID</param>
|
||||||
|
/// <returns>No content on success</returns>
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public async Task<IActionResult> DeleteQuizQuestionAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = await _quizQuestionService.DeleteQuizQuestionAsync(id, cancellationToken);
|
||||||
|
if (!result)
|
||||||
|
return NotFound();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets quiz options for a specific question.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="questionId">The question ID</param>
|
||||||
|
/// <returns>List of quiz options for the question</returns>
|
||||||
|
[HttpGet("options/{questionId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var options = await _quizQuestionService.GetQuizOptionsByQuestionAsync(questionId, cancellationToken);
|
||||||
|
return Ok(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the correct option for a question.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="questionId">The question ID</param>
|
||||||
|
/// <returns>The correct option for the question</returns>
|
||||||
|
[HttpGet("correct-option/{questionId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var option = await _quizQuestionService.GetCorrectOptionAsync(questionId, cancellationToken);
|
||||||
|
if (option == null)
|
||||||
|
return NotFound();
|
||||||
|
return Ok(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all correct options for a question (for MultipleSelect type).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="questionId">The question ID</param>
|
||||||
|
/// <returns>List of correct options for the question</returns>
|
||||||
|
[HttpGet("correct-options/{questionId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var options = await _quizQuestionService.GetCorrectOptionsAsync(questionId, cancellationToken);
|
||||||
|
return Ok(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new quiz option for a question.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="questionId">The question ID</param>
|
||||||
|
/// <param name="dto">The quiz option data</param>
|
||||||
|
/// <returns>The created quiz option</returns>
|
||||||
|
[HttpPost("options/{questionId}")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public async Task<IActionResult> CreateQuizOptionAsync(int questionId, CreateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var option = await _quizQuestionService.CreateQuizOptionAsync(questionId, dto, cancellationToken);
|
||||||
|
return CreatedAtAction(nameof(GetQuizOptionsByQuestionAsync), new { questionId }, option);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing quiz option.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The quiz option ID</param>
|
||||||
|
/// <param name="dto">The updated quiz option data</param>
|
||||||
|
/// <returns>The updated quiz option</returns>
|
||||||
|
[HttpPut("options/{id}")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public async Task<IActionResult> UpdateQuizOptionAsync(int id, UpdateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var option = await _quizQuestionService.UpdateQuizOptionAsync(id, dto, cancellationToken);
|
||||||
|
if (option == null)
|
||||||
|
return NotFound();
|
||||||
|
return Ok(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a quiz option by its ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The quiz option ID</param>
|
||||||
|
/// <returns>No content on success</returns>
|
||||||
|
[HttpDelete("options/{id}")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public async Task<IActionResult> DeleteQuizOptionAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = await _quizQuestionService.DeleteQuizOptionAsync(id, cancellationToken);
|
||||||
|
if (!result)
|
||||||
|
return NotFound();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Submits quiz answers and returns the result.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The user ID</param>
|
||||||
|
/// <param name="dto">The quiz answers submission</param>
|
||||||
|
/// <returns>The quiz result</returns>
|
||||||
|
[HttpPost("submit/{userId}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = await _quizQuestionService.SubmitQuizAnswersAsync(userId, dto, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of quiz questions for a lesson.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
|
/// <returns>The count of quiz questions for the lesson</returns>
|
||||||
|
[HttpGet("count/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var count = await _quizQuestionService.GetQuizQuestionCountForLessonAsync(lessonId, cancellationToken);
|
||||||
|
return Ok(new { lessonId, count });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the difficulty distribution for a lesson.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
|
/// <returns>The difficulty distribution for the lesson</returns>
|
||||||
|
[HttpGet("difficulty-distribution/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var distribution = await _quizQuestionService.GetDifficultyDistributionAsync(lessonId, cancellationToken);
|
||||||
|
return Ok(distribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the type distribution for a lesson.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
|
/// <returns>The type distribution for the lesson</returns>
|
||||||
|
[HttpGet("type-distribution/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var distribution = await _quizQuestionService.GetTypeDistributionAsync(lessonId, cancellationToken);
|
||||||
|
return Ok(distribution);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -134,6 +134,8 @@ try
|
||||||
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
|
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
|
||||||
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
||||||
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
||||||
|
builder.Services.AddScoped<IQuizQuestionRepository, QuizQuestionRepository>();
|
||||||
|
builder.Services.AddScoped<IQuizOptionRepository, QuizOptionRepository>();
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// INFRASTRUCTURE LAYER - AI Services
|
// INFRASTRUCTURE LAYER - AI Services
|
||||||
|
|
@ -169,6 +171,7 @@ try
|
||||||
builder.Services.AddScoped<LevelService>();
|
builder.Services.AddScoped<LevelService>();
|
||||||
builder.Services.AddScoped<LessonService>();
|
builder.Services.AddScoped<LessonService>();
|
||||||
builder.Services.AddScoped<ProgressService>();
|
builder.Services.AddScoped<ProgressService>();
|
||||||
|
builder.Services.AddScoped<QuizQuestionService>();
|
||||||
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
@ -204,10 +207,20 @@ try
|
||||||
app.MapLessonsEndpoints();
|
app.MapLessonsEndpoints();
|
||||||
|
|
||||||
// Seed database with initial data
|
// Seed database with initial data
|
||||||
if (app.Environment.IsDevelopment())
|
// Run migrations and seed in Development, or just migrations in Production/Docker
|
||||||
|
// In Docker, we can control seeding via environment variable
|
||||||
|
var shouldSeed = app.Environment.IsDevelopment() ||
|
||||||
|
(app.Configuration.GetValue<bool>("SeedDatabase"));
|
||||||
|
|
||||||
|
if (shouldSeed)
|
||||||
{
|
{
|
||||||
await app.SeedDatabaseAsync();
|
await app.SeedDatabaseAsync();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// In Production/Docker, ensure migrations are applied with retry logic
|
||||||
|
await ApplyMigrationsWithRetry(app, maxRetries: 10, delaySeconds: 5);
|
||||||
|
}
|
||||||
|
|
||||||
// Keep original WeatherForecast endpoint for reference
|
// Keep original WeatherForecast endpoint for reference
|
||||||
app.MapGet("/weatherforecast", () =>
|
app.MapGet("/weatherforecast", () =>
|
||||||
|
|
@ -230,6 +243,36 @@ try
|
||||||
.WithName("GetWeatherForecast");
|
.WithName("GetWeatherForecast");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
// Helper method to apply migrations with retry logic for Docker
|
||||||
|
static async Task ApplyMigrationsWithRetry(WebApplication app, int maxRetries, int delaySeconds)
|
||||||
|
{
|
||||||
|
int retryCount = 0;
|
||||||
|
while (retryCount < maxRetries)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
dbContext.Database.Migrate();
|
||||||
|
Log.Information("Database migrations applied in Production environment");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
retryCount++;
|
||||||
|
Log.Warning(ex, "Failed to apply database migrations (attempt {Attempt}/{MaxAttempts}). Retrying in {DelaySeconds} seconds...",
|
||||||
|
retryCount, maxRetries, delaySeconds);
|
||||||
|
if (retryCount >= maxRetries)
|
||||||
|
{
|
||||||
|
Log.Fatal(ex, "Failed to apply database migrations after {MaxAttempts} attempts", maxRetries);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
321
Tests/Unit/Application/Services/QuizQuestionServiceTests.cs
Normal file
321
Tests/Unit/Application/Services/QuizQuestionServiceTests.cs
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using GermanApp.Application.DTOs;
|
||||||
|
using GermanApp.Application.Services;
|
||||||
|
using GermanApp.Domain.Entities;
|
||||||
|
using GermanApp.Domain.Interfaces;
|
||||||
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace GermanApp.Tests.Unit.Application.Services;
|
||||||
|
|
||||||
|
[TestClass]
|
||||||
|
public class QuizQuestionServiceTests
|
||||||
|
{
|
||||||
|
private Mock<IQuizQuestionRepository> _mockQuizQuestionRepo;
|
||||||
|
private Mock<IQuizOptionRepository> _mockQuizOptionRepo;
|
||||||
|
private Mock<ILessonRepository> _mockLessonRepo;
|
||||||
|
private QuizQuestionService _service;
|
||||||
|
|
||||||
|
[TestInitialize]
|
||||||
|
public void Setup()
|
||||||
|
{
|
||||||
|
_mockQuizQuestionRepo = new Mock<IQuizQuestionRepository>();
|
||||||
|
_mockQuizOptionRepo = new Mock<IQuizOptionRepository>();
|
||||||
|
_mockLessonRepo = new Mock<ILessonRepository>();
|
||||||
|
_service = new QuizQuestionService(
|
||||||
|
_mockQuizQuestionRepo.Object,
|
||||||
|
_mockQuizOptionRepo.Object,
|
||||||
|
_mockLessonRepo.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task GetAllQuizQuestionsAsync_ReturnsMappedDtoList()
|
||||||
|
{
|
||||||
|
var lesson = Lesson.Create(1, "Test Lesson", 1, "Test Topic", "Test Description");
|
||||||
|
var questions = new List<QuizQuestion>
|
||||||
|
{
|
||||||
|
QuizQuestion.Create(1, "Question 1", QuestionType.MultipleChoice, "Answer 1", 3, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetAllOrderedAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(questions);
|
||||||
|
|
||||||
|
var result = await _service.GetAllQuizQuestionsAsync();
|
||||||
|
|
||||||
|
Assert.AreEqual(1, result.Count);
|
||||||
|
Assert.AreEqual("Question 1", result[0].QuestionText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task GetQuizQuestionByIdAsync_WithExistingId_ReturnsQuestion()
|
||||||
|
{
|
||||||
|
var question = QuizQuestion.Create(1, "Test Question", QuestionType.MultipleChoice, "Test Answer", 3, 1);
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(question);
|
||||||
|
|
||||||
|
var result = await _service.GetQuizQuestionByIdAsync(1);
|
||||||
|
|
||||||
|
Assert.IsNotNull(result);
|
||||||
|
Assert.AreEqual("Test Question", result.QuestionText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task GetQuizQuestionByIdAsync_WithNonExistingId_ReturnsNull()
|
||||||
|
{
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(999, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((QuizQuestion?)null);
|
||||||
|
|
||||||
|
var result = await _service.GetQuizQuestionByIdAsync(999);
|
||||||
|
|
||||||
|
Assert.IsNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task GetQuizQuestionsByLessonAsync_ReturnsFilteredQuestions()
|
||||||
|
{
|
||||||
|
var questions = new List<QuizQuestion>
|
||||||
|
{
|
||||||
|
QuizQuestion.Create(1, "Q1", QuestionType.MultipleChoice, "A1", 3, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(questions);
|
||||||
|
|
||||||
|
var result = await _service.GetQuizQuestionsByLessonAsync(1);
|
||||||
|
|
||||||
|
Assert.AreEqual(1, result.Count);
|
||||||
|
Assert.AreEqual("Q1", result[0].QuestionText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task GetActiveQuizQuestionsByLessonAsync_ReturnsOnlyActive()
|
||||||
|
{
|
||||||
|
var activeQuestion = QuizQuestion.Create(1, "Active", QuestionType.MultipleChoice, "A1", 3, 1);
|
||||||
|
var inactiveQuestion = QuizQuestion.Create(1, "Inactive", QuestionType.MultipleChoice, "A2", 3, 2);
|
||||||
|
inactiveQuestion.Deactivate();
|
||||||
|
|
||||||
|
var questions = new List<QuizQuestion> { activeQuestion, inactiveQuestion };
|
||||||
|
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<QuizQuestion> { activeQuestion });
|
||||||
|
|
||||||
|
var result = await _service.GetActiveQuizQuestionsByLessonAsync(1);
|
||||||
|
|
||||||
|
Assert.AreEqual(1, result.Count);
|
||||||
|
Assert.AreEqual("Active", result[0].QuestionText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task CreateQuizQuestionAsync_WithValidDtoAndValidLesson_CreatesQuestion()
|
||||||
|
{
|
||||||
|
var lesson = Lesson.Create(1, "Test Lesson", 1, "Test Topic", "Test Description");
|
||||||
|
var options = new List<CreateQuizOptionDto>
|
||||||
|
{
|
||||||
|
new CreateQuizOptionDto("Option 1", true, 1),
|
||||||
|
new CreateQuizOptionDto("Option 2", false, 2)
|
||||||
|
};
|
||||||
|
var dto = new CreateQuizQuestionDto(
|
||||||
|
1, "New Question", QuestionType.MultipleChoice, "Answer 1", 3, 1, options);
|
||||||
|
|
||||||
|
var createdQuestion = QuizQuestion.Create(1, "New Question", QuestionType.MultipleChoice, "Answer 1", 3, 1);
|
||||||
|
|
||||||
|
_mockLessonRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(lesson);
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.OrderExistsInLessonAsync(1, 1, null, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.AddAsync(It.IsAny<QuizQuestion>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(createdQuestion);
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(createdQuestion.Id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(createdQuestion);
|
||||||
|
|
||||||
|
var result = await _service.CreateQuizQuestionAsync(dto);
|
||||||
|
|
||||||
|
Assert.IsNotNull(result);
|
||||||
|
Assert.AreEqual("New Question", result.QuestionText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task UpdateQuizQuestionAsync_WithExistingQuestion_UpdatesQuestion()
|
||||||
|
{
|
||||||
|
var lesson = Lesson.Create(1, "Test Lesson", 1, "Test Topic", "Test Description");
|
||||||
|
var existing = QuizQuestion.Create(1, "Old Question", QuestionType.MultipleChoice, "Old Answer", 3, 1);
|
||||||
|
var options = new List<UpdateQuizOptionDto>
|
||||||
|
{
|
||||||
|
new UpdateQuizOptionDto(1, "Updated Option", true, 1)
|
||||||
|
};
|
||||||
|
var dto = new UpdateQuizQuestionDto(1, "Updated Question", QuestionType.TrueFalse, "New Answer", 4, 1, true, options);
|
||||||
|
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(existing);
|
||||||
|
_mockLessonRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(lesson);
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.OrderExistsInLessonAsync(1, 1, 1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.UpdateAsync(existing, It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(existing);
|
||||||
|
_mockQuizOptionRepo.Setup(r => r.DeleteByQuestionAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var result = await _service.UpdateQuizQuestionAsync(1, dto);
|
||||||
|
|
||||||
|
Assert.IsNotNull(result);
|
||||||
|
Assert.AreEqual("Updated Question", result.QuestionText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task DeleteQuizQuestionAsync_WithExistingQuestion_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var question = QuizQuestion.Create(1, "Test Question", QuestionType.MultipleChoice, "Answer", 3, 1);
|
||||||
|
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(question);
|
||||||
|
_mockQuizOptionRepo.Setup(r => r.DeleteByQuestionAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.DeleteAsync(question, It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var result = await _service.DeleteQuizQuestionAsync(1);
|
||||||
|
|
||||||
|
Assert.IsTrue(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task GetQuizOptionsByQuestionAsync_ReturnsOptions()
|
||||||
|
{
|
||||||
|
var options = new List<QuizOption>
|
||||||
|
{
|
||||||
|
QuizOption.Create(1, "Option 1", true, 1),
|
||||||
|
QuizOption.Create(1, "Option 2", false, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockQuizOptionRepo.Setup(r => r.GetByQuestionAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(options);
|
||||||
|
|
||||||
|
var result = await _service.GetQuizOptionsByQuestionAsync(1);
|
||||||
|
|
||||||
|
Assert.AreEqual(2, result.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task GetCorrectOptionAsync_ReturnsCorrectOption()
|
||||||
|
{
|
||||||
|
var option = QuizOption.Create(1, "Correct Option", true, 1);
|
||||||
|
|
||||||
|
_mockQuizOptionRepo.Setup(r => r.GetCorrectOptionAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(option);
|
||||||
|
|
||||||
|
var result = await _service.GetCorrectOptionAsync(1);
|
||||||
|
|
||||||
|
Assert.IsNotNull(result);
|
||||||
|
Assert.IsTrue(result.IsCorrect);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task CreateQuizOptionAsync_WithValidData_CreatesOption()
|
||||||
|
{
|
||||||
|
var question = QuizQuestion.Create(1, "Test Question", QuestionType.MultipleChoice, "Answer", 3, 1);
|
||||||
|
var dto = new CreateQuizOptionDto("New Option", true, 1);
|
||||||
|
var createdOption = QuizOption.Create(1, "New Option", true, 1);
|
||||||
|
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(question);
|
||||||
|
_mockQuizOptionRepo.Setup(r => r.OrderExistsForQuestionAsync(1, 1, null, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
_mockQuizOptionRepo.Setup(r => r.AddAsync(It.IsAny<QuizOption>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(createdOption);
|
||||||
|
|
||||||
|
var result = await _service.CreateQuizOptionAsync(1, dto);
|
||||||
|
|
||||||
|
Assert.IsNotNull(result);
|
||||||
|
Assert.AreEqual("New Option", result.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task SubmitQuizAnswersAsync_WithCorrectAnswers_ReturnsPassedResult()
|
||||||
|
{
|
||||||
|
var question = QuizQuestion.Create(1, "What is 2+2?", QuestionType.FillInTheBlank, "4", 3, 1);
|
||||||
|
var questions = new List<QuizQuestion> { question };
|
||||||
|
var answers = new List<QuizAnswerDto>
|
||||||
|
{
|
||||||
|
new QuizAnswerDto(1, "4", null)
|
||||||
|
};
|
||||||
|
var dto = new SubmitQuizAnswersDto(1, answers);
|
||||||
|
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(questions);
|
||||||
|
|
||||||
|
var result = await _service.SubmitQuizAnswersAsync(1, dto);
|
||||||
|
|
||||||
|
Assert.AreEqual(1, result.TotalQuestions);
|
||||||
|
Assert.AreEqual(1, result.CorrectAnswers);
|
||||||
|
Assert.AreEqual(100, result.ScorePercentage);
|
||||||
|
Assert.IsTrue(result.Passed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task SubmitQuizAnswersAsync_WithIncorrectAnswers_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
var question = QuizQuestion.Create(1, "What is 2+2?", QuestionType.FillInTheBlank, "4", 3, 1);
|
||||||
|
var questions = new List<QuizQuestion> { question };
|
||||||
|
var answers = new List<QuizAnswerDto>
|
||||||
|
{
|
||||||
|
new QuizAnswerDto(1, "5", null) // Wrong answer
|
||||||
|
};
|
||||||
|
var dto = new SubmitQuizAnswersDto(1, answers);
|
||||||
|
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(questions);
|
||||||
|
|
||||||
|
var result = await _service.SubmitQuizAnswersAsync(1, dto);
|
||||||
|
|
||||||
|
Assert.AreEqual(1, result.TotalQuestions);
|
||||||
|
Assert.AreEqual(0, result.CorrectAnswers);
|
||||||
|
Assert.AreEqual(0, result.ScorePercentage);
|
||||||
|
Assert.IsFalse(result.Passed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task GetQuizQuestionCountForLessonAsync_ReturnsCount()
|
||||||
|
{
|
||||||
|
var questions = new List<QuizQuestion>
|
||||||
|
{
|
||||||
|
QuizQuestion.Create(1, "Q1", QuestionType.MultipleChoice, "A1", 3, 1),
|
||||||
|
QuizQuestion.Create(1, "Q2", QuestionType.MultipleChoice, "A2", 3, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(questions);
|
||||||
|
|
||||||
|
var result = await _service.GetQuizQuestionCountForLessonAsync(1);
|
||||||
|
|
||||||
|
Assert.AreEqual(2, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task GetDifficultyDistributionAsync_ReturnsDistribution()
|
||||||
|
{
|
||||||
|
var questions = new List<QuizQuestion>
|
||||||
|
{
|
||||||
|
QuizQuestion.Create(1, "Easy", QuestionType.MultipleChoice, "A1", 1, 1),
|
||||||
|
QuizQuestion.Create(1, "Medium", QuestionType.MultipleChoice, "A2", 3, 2),
|
||||||
|
QuizQuestion.Create(1, "Hard", QuestionType.MultipleChoice, "A3", 5, 3)
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(questions);
|
||||||
|
|
||||||
|
var result = await _service.GetDifficultyDistributionAsync(1);
|
||||||
|
|
||||||
|
Assert.AreEqual(1, result[1]); // Easy
|
||||||
|
Assert.AreEqual(0, result[2]); // No questions with difficulty 2
|
||||||
|
Assert.AreEqual(1, result[3]); // Medium
|
||||||
|
Assert.AreEqual(0, result[4]); // No questions with difficulty 4
|
||||||
|
Assert.AreEqual(1, result[5]); // Hard
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ services:
|
||||||
ASPNETCORE_ENVIRONMENT: Development
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
ConnectionStrings__DefaultConnection: Host=db;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres
|
ConnectionStrings__DefaultConnection: Host=db;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres
|
||||||
|
SeedDatabase: "true"
|
||||||
# JWT Configuration (from Woodpecker secrets or environment)
|
# JWT Configuration (from Woodpecker secrets or environment)
|
||||||
Jwt__Key: ${JWT_KEY:-your-super-secret-key-at-least-32-characters-long}
|
Jwt__Key: ${JWT_KEY:-your-super-secret-key-at-least-32-characters-long}
|
||||||
Jwt__Issuer: ${JWT_ISSUER:-DeutschLernen}
|
Jwt__Issuer: ${JWT_ISSUER:-DeutschLernen}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue