Compare commits
No commits in common. "769c63b0053aae4a6c0937371c1c570da7f0fd3a" and "c8c5f7431d3f54b63cc42c596cdd339440dadd00" have entirely different histories.
769c63b005
...
c8c5f7431d
37 changed files with 81 additions and 6315 deletions
|
|
@ -1,151 +0,0 @@
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.DTOs;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for Quiz - used for API responses.
|
|
||||||
/// This is a read-only representation of a Quiz entity.
|
|
||||||
/// </summary>
|
|
||||||
public record QuizDto(
|
|
||||||
int Id,
|
|
||||||
int LessonId,
|
|
||||||
string LessonTitle,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int PassingScore,
|
|
||||||
int TimeLimitMinutes,
|
|
||||||
bool ShuffleQuestions,
|
|
||||||
bool IsActive,
|
|
||||||
int QuestionCount,
|
|
||||||
int TotalPoints,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime? UpdatedAt);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for creating a new Quiz.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateQuizDto(
|
|
||||||
int LessonId,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int PassingScore,
|
|
||||||
int TimeLimitMinutes,
|
|
||||||
bool ShuffleQuestions);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for updating an existing Quiz.
|
|
||||||
/// </summary>
|
|
||||||
public record UpdateQuizDto(
|
|
||||||
int LessonId,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int PassingScore,
|
|
||||||
int TimeLimitMinutes,
|
|
||||||
bool ShuffleQuestions,
|
|
||||||
bool IsActive);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for quiz with its questions.
|
|
||||||
/// </summary>
|
|
||||||
public record QuizWithQuestionsDto(
|
|
||||||
int Id,
|
|
||||||
int LessonId,
|
|
||||||
string LessonTitle,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int PassingScore,
|
|
||||||
int TimeLimitMinutes,
|
|
||||||
bool ShuffleQuestions,
|
|
||||||
bool IsActive,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime? UpdatedAt,
|
|
||||||
IReadOnlyList<QuizQuestionDto> Questions);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for quiz list item (summary).
|
|
||||||
/// </summary>
|
|
||||||
public record QuizListItemDto(
|
|
||||||
int Id,
|
|
||||||
int LessonId,
|
|
||||||
string LessonTitle,
|
|
||||||
string Title,
|
|
||||||
bool IsActive,
|
|
||||||
int QuestionCount,
|
|
||||||
DateTime CreatedAt);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for submitting quiz answers.
|
|
||||||
/// </summary>
|
|
||||||
public record SubmitQuizDto(
|
|
||||||
int QuizId,
|
|
||||||
IReadOnlyList<QuizAnswerDto> Answers);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extension methods for mapping between Quiz entity and DTOs.
|
|
||||||
/// </summary>
|
|
||||||
public static class QuizDtoExtensions
|
|
||||||
{
|
|
||||||
public static QuizDto ToDto(this Quiz quiz) => new(
|
|
||||||
quiz.Id,
|
|
||||||
quiz.LessonId,
|
|
||||||
quiz.Lesson?.Title ?? "Unknown",
|
|
||||||
quiz.Title,
|
|
||||||
quiz.Description,
|
|
||||||
quiz.PassingScore,
|
|
||||||
quiz.TimeLimitMinutes,
|
|
||||||
quiz.ShuffleQuestions,
|
|
||||||
quiz.IsActive,
|
|
||||||
quiz.Questions?.Count ?? 0,
|
|
||||||
quiz.Questions?.Sum(q => q.Points) ?? 0,
|
|
||||||
quiz.CreatedAt,
|
|
||||||
quiz.UpdatedAt);
|
|
||||||
|
|
||||||
public static QuizListItemDto ToListItemDto(this Quiz quiz) => new(
|
|
||||||
quiz.Id,
|
|
||||||
quiz.LessonId,
|
|
||||||
quiz.Lesson?.Title ?? "Unknown",
|
|
||||||
quiz.Title,
|
|
||||||
quiz.IsActive,
|
|
||||||
quiz.Questions?.Count ?? 0,
|
|
||||||
quiz.CreatedAt);
|
|
||||||
|
|
||||||
public static QuizWithQuestionsDto ToQuizWithQuestionsDto(this Quiz quiz) => new(
|
|
||||||
quiz.Id,
|
|
||||||
quiz.LessonId,
|
|
||||||
quiz.Lesson?.Title ?? "Unknown",
|
|
||||||
quiz.Title,
|
|
||||||
quiz.Description,
|
|
||||||
quiz.PassingScore,
|
|
||||||
quiz.TimeLimitMinutes,
|
|
||||||
quiz.ShuffleQuestions,
|
|
||||||
quiz.IsActive,
|
|
||||||
quiz.CreatedAt,
|
|
||||||
quiz.UpdatedAt,
|
|
||||||
quiz.Questions?.OrderBy(q => q.Order).Select(q => q.ToDto()).ToList() ?? new List<QuizQuestionDto>());
|
|
||||||
|
|
||||||
public static Quiz ToEntity(this CreateQuizDto dto) =>
|
|
||||||
Quiz.Create(
|
|
||||||
dto.LessonId,
|
|
||||||
dto.Title,
|
|
||||||
dto.Description,
|
|
||||||
dto.PassingScore,
|
|
||||||
dto.TimeLimitMinutes);
|
|
||||||
|
|
||||||
public static void UpdateFromDto(this Quiz quiz, UpdateQuizDto dto)
|
|
||||||
{
|
|
||||||
quiz.UpdateLesson(dto.LessonId);
|
|
||||||
quiz.UpdateTitle(dto.Title);
|
|
||||||
quiz.UpdateDescription(dto.Description);
|
|
||||||
quiz.UpdatePassingScore(dto.PassingScore);
|
|
||||||
quiz.UpdateTimeLimit(dto.TimeLimitMinutes);
|
|
||||||
quiz.UpdateShuffleQuestions(dto.ShuffleQuestions);
|
|
||||||
|
|
||||||
if (dto.IsActive != quiz.IsActive)
|
|
||||||
{
|
|
||||||
if (dto.IsActive)
|
|
||||||
quiz.Activate();
|
|
||||||
else
|
|
||||||
quiz.Deactivate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
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 QuizId,
|
|
||||||
int LessonId,
|
|
||||||
string QuizTitle,
|
|
||||||
string LessonTitle,
|
|
||||||
string QuestionText,
|
|
||||||
QuestionType Type,
|
|
||||||
string? CorrectAnswer,
|
|
||||||
int Points,
|
|
||||||
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 QuizId,
|
|
||||||
string QuestionText,
|
|
||||||
QuestionType Type,
|
|
||||||
string? CorrectAnswer,
|
|
||||||
int Points,
|
|
||||||
int Difficulty,
|
|
||||||
int Order,
|
|
||||||
IReadOnlyList<CreateQuizOptionDto>? Options);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for updating an existing QuizQuestion.
|
|
||||||
/// </summary>
|
|
||||||
public record UpdateQuizQuestionDto(
|
|
||||||
int QuizId,
|
|
||||||
string QuestionText,
|
|
||||||
QuestionType Type,
|
|
||||||
string? CorrectAnswer,
|
|
||||||
int Points,
|
|
||||||
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 QuizId,
|
|
||||||
int LessonId,
|
|
||||||
string QuestionText,
|
|
||||||
QuestionType Type,
|
|
||||||
int Points,
|
|
||||||
IReadOnlyList<QuizOptionDto> Options,
|
|
||||||
string? UserAnswer,
|
|
||||||
bool IsCorrect);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for submitting quiz answers.
|
|
||||||
/// </summary>
|
|
||||||
public record SubmitQuizAnswersDto(
|
|
||||||
int QuizId,
|
|
||||||
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 QuizId,
|
|
||||||
int LessonId,
|
|
||||||
string QuizTitle,
|
|
||||||
int TotalQuestions,
|
|
||||||
int TotalPoints,
|
|
||||||
int ScorePoints,
|
|
||||||
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,
|
|
||||||
int Points,
|
|
||||||
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.QuizId,
|
|
||||||
quizQuestion.Quiz?.LessonId ?? 0,
|
|
||||||
quizQuestion.Quiz?.Title ?? "Unknown",
|
|
||||||
quizQuestion.Quiz?.Lesson?.Title ?? "Unknown",
|
|
||||||
quizQuestion.QuestionText,
|
|
||||||
quizQuestion.Type,
|
|
||||||
quizQuestion.CorrectAnswer,
|
|
||||||
quizQuestion.Points,
|
|
||||||
quizQuestion.Difficulty,
|
|
||||||
quizQuestion.Order,
|
|
||||||
quizQuestion.IsActive,
|
|
||||||
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.QuizId,
|
|
||||||
dto.QuestionText,
|
|
||||||
dto.Type,
|
|
||||||
dto.CorrectAnswer ?? string.Empty,
|
|
||||||
dto.Points,
|
|
||||||
dto.Difficulty,
|
|
||||||
dto.Order);
|
|
||||||
|
|
||||||
public static void UpdateFromDto(this QuizQuestion quizQuestion, UpdateQuizQuestionDto dto)
|
|
||||||
{
|
|
||||||
quizQuestion.UpdateQuiz(dto.QuizId);
|
|
||||||
quizQuestion.UpdateQuestionText(dto.QuestionText);
|
|
||||||
quizQuestion.UpdateType(dto.Type);
|
|
||||||
quizQuestion.UpdateCorrectAnswer(dto.CorrectAnswer ?? string.Empty);
|
|
||||||
quizQuestion.UpdatePoints(dto.Points);
|
|
||||||
quizQuestion.UpdateDifficulty(dto.Difficulty);
|
|
||||||
quizQuestion.UpdateOrder(dto.Order);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Domain service for managing lesson unlocking business logic.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class LessonUnlockService
|
|
||||||
{
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
private readonly IUserProgressRepository _userProgressRepository;
|
|
||||||
|
|
||||||
public LessonUnlockService(
|
|
||||||
ILessonRepository lessonRepository,
|
|
||||||
IUserProgressRepository userProgressRepository)
|
|
||||||
{
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
_userProgressRepository = userProgressRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if the next lesson in a level is unlocked for the user.
|
|
||||||
/// A lesson is unlocked if:
|
|
||||||
/// - It's the first lesson in the level, OR
|
|
||||||
/// - The previous lesson in the level has been completed
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="currentLessonId">The current lesson ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the next lesson is unlocked, false otherwise</returns>
|
|
||||||
public virtual async Task<bool> IsNextLessonUnlockedAsync(
|
|
||||||
int userId,
|
|
||||||
int currentLessonId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var currentLesson = await _lessonRepository.GetByIdAsync(currentLessonId, cancellationToken);
|
|
||||||
if (currentLesson == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// If this is the first lesson in the level, the next one is always unlocked
|
|
||||||
var firstLesson = await _lessonRepository.GetFirstLessonInLevelAsync(
|
|
||||||
currentLesson.LevelId,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (firstLesson?.Id == currentLessonId)
|
|
||||||
{
|
|
||||||
var nextLesson = await _lessonRepository.GetNextLessonAsync(currentLessonId, cancellationToken);
|
|
||||||
return nextLesson != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the user has completed the current lesson
|
|
||||||
var hasCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(
|
|
||||||
userId,
|
|
||||||
currentLessonId,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return hasCompleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the next lesson ID that should be unlocked for a user after completing a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="completedLessonId">The ID of the lesson that was just completed</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The ID of the next lesson, or null if no more lessons in the level</returns>
|
|
||||||
public virtual async Task<int?> GetNextLessonIdAsync(
|
|
||||||
int userId,
|
|
||||||
int completedLessonId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var currentLesson = await _lessonRepository.GetByIdAsync(completedLessonId, cancellationToken);
|
|
||||||
if (currentLesson == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var nextLesson = await _lessonRepository.GetNextLessonAsync(completedLessonId, cancellationToken);
|
|
||||||
return nextLesson?.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all lessons that are accessible to a user (completed lessons + next unlocked lesson).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of accessible lesson IDs</returns>
|
|
||||||
public virtual async Task<IReadOnlyList<int>> GetAccessibleLessonIdsAsync(
|
|
||||||
int userId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonRepository.GetAccessibleLessonsAsync(userId, cancellationToken);
|
|
||||||
return lessons.Select(l => l.Id).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a specific lesson is accessible to a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="lessonId">The lesson ID to check</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the lesson is accessible, false otherwise</returns>
|
|
||||||
public virtual async Task<bool> IsLessonAccessibleAsync(
|
|
||||||
int userId,
|
|
||||||
int lessonId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var accessibleLessons = await GetAccessibleLessonIdsAsync(userId, cancellationToken);
|
|
||||||
return accessibleLessons.Contains(lessonId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service for calculating level completion metrics.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class LevelCompletionCalculator
|
|
||||||
{
|
|
||||||
private readonly ILevelRepository _levelRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
private readonly IUserProgressRepository _userProgressRepository;
|
|
||||||
|
|
||||||
public LevelCompletionCalculator(
|
|
||||||
ILevelRepository levelRepository,
|
|
||||||
ILessonRepository lessonRepository,
|
|
||||||
IUserProgressRepository userProgressRepository)
|
|
||||||
{
|
|
||||||
_levelRepository = levelRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
_userProgressRepository = userProgressRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates the completion percentage for a specific level for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Completion percentage (0-100)</returns>
|
|
||||||
public virtual async Task<double> CalculateLevelCompletionPercentageAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Get total lessons in the level
|
|
||||||
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
|
|
||||||
var totalLessons = lessons.Count;
|
|
||||||
|
|
||||||
if (totalLessons == 0)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
// Get completed lessons count for user in this level
|
|
||||||
var completedCount = await _userProgressRepository.GetLevelCompletionPercentageAsync(
|
|
||||||
userId,
|
|
||||||
levelId,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// The repository method returns percentage, but we need the count
|
|
||||||
// Let's recalculate properly
|
|
||||||
var completedLessons = 0;
|
|
||||||
foreach (var lesson in lessons)
|
|
||||||
{
|
|
||||||
var hasCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(
|
|
||||||
userId,
|
|
||||||
lesson.Id,
|
|
||||||
cancellationToken);
|
|
||||||
if (hasCompleted)
|
|
||||||
completedLessons++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (double)completedLessons / totalLessons * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets completion information for all levels for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of level completion DTOs</returns>
|
|
||||||
public virtual async Task<IReadOnlyList<LevelCompletionDto>> CalculateAllLevelCompletionsAsync(
|
|
||||||
int userId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
|
|
||||||
var completions = new List<LevelCompletionDto>();
|
|
||||||
|
|
||||||
foreach (var level in levels)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonRepository.GetByLevelAsync(level.Id, cancellationToken);
|
|
||||||
var totalLessons = lessons.Count;
|
|
||||||
|
|
||||||
var completedCount = 0;
|
|
||||||
foreach (var lesson in lessons)
|
|
||||||
{
|
|
||||||
var hasCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(
|
|
||||||
userId,
|
|
||||||
lesson.Id,
|
|
||||||
cancellationToken);
|
|
||||||
if (hasCompleted)
|
|
||||||
completedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
var percentage = totalLessons > 0 ? (double)completedCount / totalLessons * 100 : 0;
|
|
||||||
|
|
||||||
completions.Add(new LevelCompletionDto(
|
|
||||||
level.Id,
|
|
||||||
level.Name,
|
|
||||||
level.Code,
|
|
||||||
totalLessons,
|
|
||||||
completedCount,
|
|
||||||
percentage
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return completions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a user has completed all lessons in a level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if all lessons are completed, false otherwise</returns>
|
|
||||||
public virtual async Task<bool> IsLevelFullyCompletedAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var percentage = await CalculateLevelCompletionPercentageAsync(userId, levelId, cancellationToken);
|
|
||||||
return percentage >= 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of completed lessons in a level for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Number of completed lessons</returns>
|
|
||||||
public virtual async Task<int> GetCompletedLessonCountAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
|
|
||||||
var completedCount = 0;
|
|
||||||
|
|
||||||
foreach (var lesson in lessons)
|
|
||||||
{
|
|
||||||
var hasCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(
|
|
||||||
userId,
|
|
||||||
lesson.Id,
|
|
||||||
cancellationToken);
|
|
||||||
if (hasCompleted)
|
|
||||||
completedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return completedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of total lessons in a level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Total number of lessons</returns>
|
|
||||||
public virtual async Task<int> GetTotalLessonCountAsync(
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
|
|
||||||
return lessons.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the next level ID that should be unlocked for a user.
|
|
||||||
/// Returns the next level if the current level is fully completed.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="currentLevelId">The current level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The next level ID, or null if no more levels</returns>
|
|
||||||
public virtual async Task<int?> GetNextLevelIdAsync(
|
|
||||||
int userId,
|
|
||||||
int currentLevelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var isCompleted = await IsLevelFullyCompletedAsync(userId, currentLevelId, cancellationToken);
|
|
||||||
|
|
||||||
if (!isCompleted)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var nextLevel = await _levelRepository.GetNextLevelAsync(currentLevelId, cancellationToken);
|
|
||||||
return nextLevel?.Id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
using GermanApp.Application.Models;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Configuration;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Application service for Mistral AI text generation.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class MistralService : IMistralService
|
|
||||||
{
|
|
||||||
private readonly IMistralConnector _connector;
|
|
||||||
private readonly MistralConfig _config;
|
|
||||||
|
|
||||||
public MistralService(
|
|
||||||
IMistralConnector connector,
|
|
||||||
IOptions<MistralConfig> config)
|
|
||||||
{
|
|
||||||
_connector = connector;
|
|
||||||
_config = config.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates text from a prompt using Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> GenerateTextAsync(
|
|
||||||
string prompt,
|
|
||||||
string? model = null,
|
|
||||||
float temperature = 0.7f,
|
|
||||||
int? maxTokens = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
model ??= _config.DefaultModel;
|
|
||||||
maxTokens ??= 500;
|
|
||||||
|
|
||||||
var request = new MistralRequest
|
|
||||||
{
|
|
||||||
Model = model,
|
|
||||||
Prompt = prompt,
|
|
||||||
Temperature = temperature,
|
|
||||||
MaxTokens = maxTokens.Value
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await _connector.CompleteAsync(request, cancellationToken);
|
|
||||||
|
|
||||||
if (response.Choices == null || response.Choices.Count == 0)
|
|
||||||
throw new InvalidOperationException("No choices returned from Mistral API");
|
|
||||||
|
|
||||||
return response.Choices[0].Text ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates text from chat messages using Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> GenerateChatAsync(
|
|
||||||
IReadOnlyList<(string role, string content)> messages,
|
|
||||||
string? model = null,
|
|
||||||
float temperature = 0.7f,
|
|
||||||
int? maxTokens = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
model ??= _config.DefaultModel;
|
|
||||||
maxTokens ??= 500;
|
|
||||||
|
|
||||||
var chatMessages = messages
|
|
||||||
.Select(m => new MistralMessage { Role = m.role, Content = m.content })
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var request = new MistralChatRequest
|
|
||||||
{
|
|
||||||
Model = model,
|
|
||||||
Messages = chatMessages,
|
|
||||||
Temperature = temperature,
|
|
||||||
MaxTokens = maxTokens.Value
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await _connector.ChatAsync(request, cancellationToken);
|
|
||||||
|
|
||||||
if (response.Choices == null || response.Choices.Count == 0)
|
|
||||||
throw new InvalidOperationException("No choices returned from Mistral API");
|
|
||||||
|
|
||||||
return response.Choices[0].Message?.Content ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a story based on lesson context and vocabulary.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> GenerateStoryAsync(
|
|
||||||
string level,
|
|
||||||
string topic,
|
|
||||||
IReadOnlyList<string> vocabularyWords,
|
|
||||||
int length = 200,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var prompt = BuildStoryPrompt(level, topic, vocabularyWords, length);
|
|
||||||
|
|
||||||
return await GenerateTextAsync(
|
|
||||||
prompt,
|
|
||||||
model: _config.DefaultModel,
|
|
||||||
temperature: 0.8f,
|
|
||||||
maxTokens: 1000,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides feedback on user writing.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> GenerateWritingFeedbackAsync(
|
|
||||||
string userText,
|
|
||||||
string level,
|
|
||||||
string? prompt = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var messages = new List<(string role, string content)>
|
|
||||||
{
|
|
||||||
("system", BuildFeedbackSystemPrompt(level)),
|
|
||||||
("user", prompt ?? "Please provide feedback on the following German text:"),
|
|
||||||
("user", userText)
|
|
||||||
};
|
|
||||||
|
|
||||||
return await GenerateChatAsync(
|
|
||||||
messages,
|
|
||||||
model: _config.DefaultModel,
|
|
||||||
temperature: 0.3f, // Lower temperature for more deterministic feedback
|
|
||||||
maxTokens: 800,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Mistral API connection.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Send a simple test prompt
|
|
||||||
var testPrompt = "Say 'test successful'";
|
|
||||||
var result = await GenerateTextAsync(
|
|
||||||
testPrompt,
|
|
||||||
maxTokens: 10,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
|
|
||||||
return result.Contains("test successful", System.StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds a prompt for story generation.
|
|
||||||
/// </summary>
|
|
||||||
private string BuildStoryPrompt(string level, string topic, IReadOnlyList<string> vocabularyWords, int length)
|
|
||||||
{
|
|
||||||
var vocabularyList = string.Join(", ", vocabularyWords);
|
|
||||||
|
|
||||||
return $"You are a helpful German language teacher. Create an engaging story for a {level} level learner.\n\nRequirements:\n- Topic: {topic}\n- Length: approximately {length} words\n- Use these German vocabulary words: {vocabularyList}\n- Write in German language\n- Appropriate for A1-A2 learners (simple sentences, common vocabulary)\n- Include dialogue\n- End with a question for the reader\n\nWrite only the story text, no additional explanation or formatting."
|
|
||||||
.Replace("\n\n", "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds a system prompt for writing feedback.
|
|
||||||
/// </summary>
|
|
||||||
private string BuildFeedbackSystemPrompt(string level)
|
|
||||||
{
|
|
||||||
return $"You are a helpful German language tutor. Provide constructive feedback on the user's German writing.\n\nGuidelines:\n- Respond in English\n- First, identify and correct any grammar mistakes\n- Then, provide suggestions for improvement\n- Finally, give encouragement\n- Be specific and helpful\n- Keep feedback concise (3-5 sentences)\n- Level: {level}"
|
|
||||||
.Replace("\n\n", "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,506 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Exceptions;
|
|
||||||
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 IQuizRepository _quizRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
private readonly ProgressService _progressService;
|
|
||||||
|
|
||||||
public QuizQuestionService(
|
|
||||||
IQuizQuestionRepository quizQuestionRepository,
|
|
||||||
IQuizOptionRepository quizOptionRepository,
|
|
||||||
IQuizRepository quizRepository,
|
|
||||||
ILessonRepository lessonRepository,
|
|
||||||
ProgressService progressService)
|
|
||||||
{
|
|
||||||
_quizQuestionRepository = quizQuestionRepository;
|
|
||||||
_quizOptionRepository = quizOptionRepository;
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
_progressService = progressService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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 quiz exists
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(dto.QuizId, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
throw new NotFoundException("Quiz does not exist");
|
|
||||||
|
|
||||||
// Validate question order uniqueness within the quiz
|
|
||||||
await ValidateQuestionOrderUniquenessAsync(0, dto.QuizId, 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 quiz exists
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(dto.QuizId, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
throw new NotFoundException("Quiz does not exist");
|
|
||||||
|
|
||||||
// Validate question order uniqueness within the quiz
|
|
||||||
await ValidateQuestionOrderUniquenessAsync(id, dto.QuizId, 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 quiz.
|
|
||||||
/// </summary>
|
|
||||||
private async Task ValidateQuestionOrderUniquenessAsync(int excludeQuestionId, int quizId, int order, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var exists = await _quizQuestionRepository.OrderExistsInQuizAsync(quizId, order, excludeQuestionId, cancellationToken);
|
|
||||||
if (exists)
|
|
||||||
throw new ValidationException("A quiz question with this order already exists in the specified quiz");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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 for a specific quiz and returns the result.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizResultDto> SubmitQuizAnswersForQuizAsync(int userId, SubmitQuizDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetWithQuestionsAsync(dto.QuizId, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
throw new NotFoundException("Quiz does not exist");
|
|
||||||
|
|
||||||
var questions = quiz.Questions?.Where(q => q.IsActive).OrderBy(q => q.Order).ToList() ?? new List<QuizQuestion>();
|
|
||||||
var totalQuestions = questions.Count;
|
|
||||||
var totalPoints = questions.Sum(q => q.Points);
|
|
||||||
var scorePoints = 0;
|
|
||||||
var questionResults = new List<QuizQuestionResultDto>();
|
|
||||||
|
|
||||||
// Get user's answers (using QuizAnswerDto which has the same fields as SubmitAnswerDto)
|
|
||||||
var userAnswers = dto.Answers.ToDictionary(a => a.QuizQuestionId);
|
|
||||||
|
|
||||||
foreach (var question in questions)
|
|
||||||
{
|
|
||||||
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.Trim().Equals(correctAnswer.Trim(), StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case QuestionType.Matching:
|
|
||||||
// For matching, we would need more complex logic
|
|
||||||
// This is a simplified implementation
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCorrect)
|
|
||||||
scorePoints += question.Points;
|
|
||||||
|
|
||||||
questionResults.Add(new QuizQuestionResultDto(
|
|
||||||
question.Id,
|
|
||||||
question.QuestionText,
|
|
||||||
question.Type,
|
|
||||||
question.Points,
|
|
||||||
isCorrect,
|
|
||||||
correctAnswer,
|
|
||||||
userAnswerText));
|
|
||||||
}
|
|
||||||
|
|
||||||
var scorePercentage = totalPoints > 0 ? (double)scorePoints / totalPoints * 100 : 0;
|
|
||||||
var passed = quiz.IsPassed(scorePercentage);
|
|
||||||
|
|
||||||
// Update user progress if quiz is passed
|
|
||||||
if (passed && userId > 0)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _progressService.MarkLessonAsCompletedAsync(
|
|
||||||
userId,
|
|
||||||
quiz.LessonId,
|
|
||||||
(int)scorePercentage,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Log error but don't fail the quiz submission
|
|
||||||
// In production, you would use a logger here
|
|
||||||
// For now, we'll just swallow the exception to keep the quiz submission working
|
|
||||||
// Consider adding logging: _logger.LogError(ex, "Failed to update progress for user {UserId} lesson {LessonId}", userId, quiz.LessonId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new QuizResultDto(
|
|
||||||
quiz.Id,
|
|
||||||
quiz.LessonId,
|
|
||||||
quiz.Title,
|
|
||||||
totalQuestions,
|
|
||||||
totalPoints,
|
|
||||||
scorePoints,
|
|
||||||
scorePercentage,
|
|
||||||
passed,
|
|
||||||
TimeSpan.Zero, // Would be calculated based on actual time taken
|
|
||||||
questionResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Submits quiz answers and returns the result (legacy - converts SubmitQuizAnswersDto to SubmitQuizDto).
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizResultDto> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Convert legacy DTO to new format
|
|
||||||
// SubmitQuizAnswersDto has QuizId and Answers (IReadOnlyList<QuizAnswerDto>)
|
|
||||||
// SubmitQuizDto has QuizId and Answers (IReadOnlyList<QuizAnswerDto>)
|
|
||||||
// They're the same, so just create a new SubmitQuizDto
|
|
||||||
var newDto = new SubmitQuizDto(dto.QuizId, dto.Answers);
|
|
||||||
return await SubmitQuizAnswersForQuizAsync(userId, newDto, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Quiz-based methods (QuizId)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by quiz ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetByQuizAsync(quizId, cancellationToken);
|
|
||||||
return questions.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quiz questions by quiz ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetActiveByQuizAsync(quizId, cancellationToken);
|
|
||||||
return questions.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a random set of quiz questions for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetRandomQuestionsForQuizAsync(quizId, count, cancellationToken);
|
|
||||||
return questions.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total points for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<int> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _quizQuestionRepository.GetTotalPointsForQuizAsync(quizId, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Exceptions;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Application service for managing quizzes.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class QuizService
|
|
||||||
{
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
private readonly IQuizQuestionRepository _quizQuestionRepository;
|
|
||||||
private readonly IQuizOptionRepository _quizOptionRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
|
|
||||||
public QuizService(
|
|
||||||
IQuizRepository quizRepository,
|
|
||||||
IQuizQuestionRepository quizQuestionRepository,
|
|
||||||
IQuizOptionRepository quizOptionRepository,
|
|
||||||
ILessonRepository lessonRepository)
|
|
||||||
{
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
_quizQuestionRepository = quizQuestionRepository;
|
|
||||||
_quizOptionRepository = quizOptionRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quizzes.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizDto>> GetAllQuizzesAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetAllOrderedAsync(cancellationToken);
|
|
||||||
return quizzes.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quizzes as list items (summary).
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizListItemDto>> GetAllQuizListItemsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetAllOrderedAsync(cancellationToken);
|
|
||||||
return quizzes.Select(q => q.ToListItemDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a quiz by its ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizDto?> GetQuizByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
return quiz?.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a quiz with its questions by ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizWithQuestionsDto?> GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetWithQuestionsAsync(id, cancellationToken);
|
|
||||||
return quiz?.ToQuizWithQuestionsDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quizzes by lesson ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizDto>> GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return quizzes.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quizzes by lesson ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizDto>> GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return quizzes.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the first quiz for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizDto?> GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetFirstByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return quiz?.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new quiz.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizDto> CreateQuizAsync(CreateQuizDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Validate that the lesson exists
|
|
||||||
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
|
|
||||||
if (lesson == null)
|
|
||||||
throw new NotFoundException("Lesson does not exist");
|
|
||||||
|
|
||||||
// Validate business rules
|
|
||||||
if (dto.PassingScore < 0 || dto.PassingScore > 100)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Passing score must be between 0 and 100");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.TimeLimitMinutes < 0)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Time limit cannot be negative");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert DTO to domain entity
|
|
||||||
var quiz = dto.ToEntity();
|
|
||||||
|
|
||||||
// Add to repository
|
|
||||||
var createdQuiz = await _quizRepository.AddAsync(quiz, cancellationToken);
|
|
||||||
|
|
||||||
// Return DTO representation
|
|
||||||
return createdQuiz.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing quiz.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizDto?> UpdateQuizAsync(int id, UpdateQuizDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Validate that the lesson exists
|
|
||||||
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
|
|
||||||
if (lesson == null)
|
|
||||||
throw new NotFoundException("Lesson does not exist");
|
|
||||||
|
|
||||||
// Validate business rules
|
|
||||||
if (dto.PassingScore < 0 || dto.PassingScore > 100)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Passing score must be between 0 and 100");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.TimeLimitMinutes < 0)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Time limit cannot be negative");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the quiz
|
|
||||||
quiz.UpdateFromDto(dto);
|
|
||||||
await _quizRepository.UpdateAsync(quiz, cancellationToken);
|
|
||||||
|
|
||||||
// Reload and return
|
|
||||||
var updatedQuiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
return updatedQuiz?.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a quiz by its ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> DeleteQuizAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// First, delete all options for all questions in this quiz
|
|
||||||
var questions = await _quizQuestionRepository.GetByQuizAsync(id, cancellationToken);
|
|
||||||
foreach (var question in questions)
|
|
||||||
{
|
|
||||||
await _quizOptionRepository.DeleteByQuestionAsync(question.Id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then delete all questions for this quiz
|
|
||||||
foreach (var question in questions)
|
|
||||||
{
|
|
||||||
await _quizQuestionRepository.DeleteAsync(question, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally, delete the quiz itself
|
|
||||||
await _quizRepository.DeleteAsync(quiz, cancellationToken);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz exists for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _quizRepository.ExistsByLessonAsync(lessonId, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz exists by ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _quizRepository.ExistsAsync(id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total number of quizzes.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<int> GetTotalQuizCountAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetAllAsync(cancellationToken);
|
|
||||||
return quizzes.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total number of quizzes for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<int> GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return quizzes.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quizzes by passing score range.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizDto>> GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetAllAsync(cancellationToken);
|
|
||||||
return quizzes.Where(q => q.PassingScore >= minScore && q.PassingScore <= maxScore)
|
|
||||||
.Select(q => q.ToDto())
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quizzes.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizDto>> GetActiveQuizzesAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetAllAsync(cancellationToken);
|
|
||||||
return quizzes.Where(q => q.IsActive)
|
|
||||||
.Select(q => q.ToDto())
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Activates a quiz.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizDto?> ActivateQuizAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
quiz.Activate();
|
|
||||||
await _quizRepository.UpdateAsync(quiz, cancellationToken);
|
|
||||||
|
|
||||||
var updatedQuiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
return updatedQuiz?.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deactivates a quiz.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizDto?> DeactivateQuizAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
quiz.Deactivate();
|
|
||||||
await _quizRepository.UpdateAsync(quiz, cancellationToken);
|
|
||||||
|
|
||||||
var updatedQuiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
return updatedQuiz?.ToDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Exceptions;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.UseCases.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command for creating a new quiz.
|
|
||||||
/// This follows the CQRS pattern - commands represent write operations.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateQuizCommand(CreateQuizDto QuizData) : ICommand<QuizDto>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler for the CreateQuizCommand.
|
|
||||||
/// </summary>
|
|
||||||
public class CreateQuizCommandHandler : ICommandHandler<CreateQuizCommand, QuizDto>
|
|
||||||
{
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
|
|
||||||
public CreateQuizCommandHandler(
|
|
||||||
IQuizRepository quizRepository,
|
|
||||||
ILessonRepository lessonRepository)
|
|
||||||
{
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<QuizDto> Handle(CreateQuizCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// Validate that the lesson exists
|
|
||||||
var lesson = await _lessonRepository.GetByIdAsync(command.QuizData.LessonId, cancellationToken);
|
|
||||||
if (lesson == null)
|
|
||||||
throw new NotFoundException("Lesson does not exist");
|
|
||||||
|
|
||||||
// Validate business rules
|
|
||||||
if (command.QuizData.PassingScore < 0 || command.QuizData.PassingScore > 100)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Passing score must be between 0 and 100");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.QuizData.TimeLimitMinutes < 0)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Time limit cannot be negative");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert DTO to domain entity
|
|
||||||
var quiz = command.QuizData.ToEntity();
|
|
||||||
|
|
||||||
// Add to repository
|
|
||||||
var createdQuiz = await _quizRepository.AddAsync(quiz, cancellationToken);
|
|
||||||
|
|
||||||
// Return DTO representation
|
|
||||||
return createdQuiz.ToDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command for updating an existing quiz.
|
|
||||||
/// </summary>
|
|
||||||
public record UpdateQuizCommand(int Id, UpdateQuizDto QuizData) : ICommand<QuizDto?>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler for the UpdateQuizCommand.
|
|
||||||
/// </summary>
|
|
||||||
public class UpdateQuizCommandHandler : ICommandHandler<UpdateQuizCommand, QuizDto?>
|
|
||||||
{
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
|
|
||||||
public UpdateQuizCommandHandler(
|
|
||||||
IQuizRepository quizRepository,
|
|
||||||
ILessonRepository lessonRepository)
|
|
||||||
{
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<QuizDto?> Handle(UpdateQuizCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(command.Id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Validate that the lesson exists
|
|
||||||
var lesson = await _lessonRepository.GetByIdAsync(command.QuizData.LessonId, cancellationToken);
|
|
||||||
if (lesson == null)
|
|
||||||
throw new NotFoundException("Lesson does not exist");
|
|
||||||
|
|
||||||
// Validate business rules
|
|
||||||
if (command.QuizData.PassingScore < 0 || command.QuizData.PassingScore > 100)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Passing score must be between 0 and 100");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.QuizData.TimeLimitMinutes < 0)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Time limit cannot be negative");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the quiz
|
|
||||||
quiz.UpdateFromDto(command.QuizData);
|
|
||||||
await _quizRepository.UpdateAsync(quiz, cancellationToken);
|
|
||||||
|
|
||||||
// Reload and return
|
|
||||||
var updatedQuiz = await _quizRepository.GetByIdAsync(command.Id, cancellationToken);
|
|
||||||
return updatedQuiz?.ToDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command for deleting a quiz.
|
|
||||||
/// </summary>
|
|
||||||
public record DeleteQuizCommand(int Id) : ICommand<bool>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler for the DeleteQuizCommand.
|
|
||||||
/// </summary>
|
|
||||||
public class DeleteQuizCommandHandler : ICommandHandler<DeleteQuizCommand, bool>
|
|
||||||
{
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
|
|
||||||
public DeleteQuizCommandHandler(IQuizRepository quizRepository)
|
|
||||||
{
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> Handle(DeleteQuizCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(command.Id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
await _quizRepository.DeleteAsync(quiz, cancellationToken);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command for getting a quiz with its questions.
|
|
||||||
/// </summary>
|
|
||||||
public record GetQuizWithQuestionsCommand(int Id) : ICommand<QuizWithQuestionsDto?>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler for the GetQuizWithQuestionsCommand.
|
|
||||||
/// </summary>
|
|
||||||
public class GetQuizWithQuestionsCommandHandler : ICommandHandler<GetQuizWithQuestionsCommand, QuizWithQuestionsDto?>
|
|
||||||
{
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
|
|
||||||
public GetQuizWithQuestionsCommandHandler(IQuizRepository quizRepository)
|
|
||||||
{
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<QuizWithQuestionsDto?> Handle(GetQuizWithQuestionsCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetWithQuestionsAsync(command.Id, cancellationToken);
|
|
||||||
return quiz?.ToQuizWithQuestionsDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
namespace GermanApp.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a quiz that can be associated with a lesson.
|
|
||||||
/// A quiz contains multiple questions and has a passing score threshold.
|
|
||||||
/// </summary>
|
|
||||||
public class Quiz
|
|
||||||
{
|
|
||||||
public int Id { get; private set; }
|
|
||||||
public int LessonId { get; private set; }
|
|
||||||
public string Title { get; private set; } = string.Empty;
|
|
||||||
public string? Description { get; private set; }
|
|
||||||
public int PassingScore { get; private set; } = 80; // Default 80%
|
|
||||||
public int TimeLimitMinutes { get; private set; } = 0; // 0 = no limit
|
|
||||||
public bool IsActive { get; private set; } = true;
|
|
||||||
public bool ShuffleQuestions { get; private set; } = true;
|
|
||||||
public DateTime CreatedAt { get; private set; }
|
|
||||||
public DateTime? UpdatedAt { get; private set; }
|
|
||||||
|
|
||||||
// Navigation property
|
|
||||||
public virtual Lesson? Lesson { get; private set; }
|
|
||||||
public virtual ICollection<QuizQuestion> Questions { get; private set; } = new List<QuizQuestion>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Constructor for EF Core deserialization.
|
|
||||||
/// </summary>
|
|
||||||
private Quiz() { }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory method to create a new quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">ID of the associated lesson</param>
|
|
||||||
/// <param name="title">Title of the quiz</param>
|
|
||||||
/// <param name="description">Optional description</param>
|
|
||||||
/// <param name="passingScore">Passing score percentage (default 80)</param>
|
|
||||||
/// <param name="timeLimitMinutes">Time limit in minutes (0 for no limit)</param>
|
|
||||||
public static Quiz Create(
|
|
||||||
int lessonId,
|
|
||||||
string title,
|
|
||||||
string? description = null,
|
|
||||||
int passingScore = 80,
|
|
||||||
int timeLimitMinutes = 0)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(title))
|
|
||||||
throw new ArgumentException("Quiz title cannot be empty", nameof(title));
|
|
||||||
|
|
||||||
if (passingScore < 0 || passingScore > 100)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(passingScore), "Passing score must be between 0 and 100");
|
|
||||||
|
|
||||||
if (timeLimitMinutes < 0)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(timeLimitMinutes), "Time limit cannot be negative");
|
|
||||||
|
|
||||||
return new Quiz
|
|
||||||
{
|
|
||||||
LessonId = lessonId,
|
|
||||||
Title = title,
|
|
||||||
Description = description,
|
|
||||||
PassingScore = passingScore,
|
|
||||||
TimeLimitMinutes = timeLimitMinutes,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the quiz title.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateTitle(string newTitle)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(newTitle))
|
|
||||||
throw new ArgumentException("Quiz title cannot be empty", nameof(newTitle));
|
|
||||||
|
|
||||||
Title = newTitle;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the quiz description.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateDescription(string? newDescription)
|
|
||||||
{
|
|
||||||
Description = newDescription;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the passing score.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdatePassingScore(int newPassingScore)
|
|
||||||
{
|
|
||||||
if (newPassingScore < 0 || newPassingScore > 100)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(newPassingScore), "Passing score must be between 0 and 100");
|
|
||||||
|
|
||||||
PassingScore = newPassingScore;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the time limit.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateTimeLimit(int newTimeLimitMinutes)
|
|
||||||
{
|
|
||||||
if (newTimeLimitMinutes < 0)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(newTimeLimitMinutes), "Time limit cannot be negative");
|
|
||||||
|
|
||||||
TimeLimitMinutes = newTimeLimitMinutes;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the associated lesson.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateLesson(int newLessonId)
|
|
||||||
{
|
|
||||||
LessonId = newLessonId;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the shuffle questions setting.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateShuffleQuestions(bool newShuffleQuestions)
|
|
||||||
{
|
|
||||||
ShuffleQuestions = newShuffleQuestions;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Activates the quiz.
|
|
||||||
/// </summary>
|
|
||||||
public void Activate()
|
|
||||||
{
|
|
||||||
IsActive = true;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deactivates the quiz.
|
|
||||||
/// </summary>
|
|
||||||
public void Deactivate()
|
|
||||||
{
|
|
||||||
IsActive = false;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a question to this quiz.
|
|
||||||
/// </summary>
|
|
||||||
public void AddQuestion(QuizQuestion question)
|
|
||||||
{
|
|
||||||
if (question == null)
|
|
||||||
throw new ArgumentNullException(nameof(question));
|
|
||||||
|
|
||||||
Questions.Add(question);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes a question from this quiz.
|
|
||||||
/// </summary>
|
|
||||||
public void RemoveQuestion(QuizQuestion question)
|
|
||||||
{
|
|
||||||
if (question == null)
|
|
||||||
throw new ArgumentNullException(nameof(question));
|
|
||||||
|
|
||||||
Questions.Remove(question);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if the quiz is passed based on a score percentage.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsPassed(double scorePercentage) => scorePercentage >= PassingScore;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates the score percentage based on correct answers.
|
|
||||||
/// </summary>
|
|
||||||
public double CalculateScore(int correctAnswers, int totalQuestions)
|
|
||||||
{
|
|
||||||
if (totalQuestions == 0)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
return (double)correctAnswers / totalQuestions * 100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,285 +0,0 @@
|
||||||
namespace GermanApp.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a quiz question for a quiz.
|
|
||||||
/// Each question has a type (multiple choice, true/false, fill-in-the-blank, etc.)
|
|
||||||
/// and is associated with a specific quiz.
|
|
||||||
/// </summary>
|
|
||||||
public class QuizQuestion
|
|
||||||
{
|
|
||||||
public int Id { get; private set; }
|
|
||||||
public int QuizId { get; private set; }
|
|
||||||
public string QuestionText { get; private set; } = string.Empty;
|
|
||||||
public QuestionType Type { get; private set; }
|
|
||||||
public string? CorrectAnswer { get; private set; }
|
|
||||||
public int Points { get; private set; } = 1; // Points for this question
|
|
||||||
public int Difficulty { get; private set; } // 1-5 scale
|
|
||||||
public int Order { get; private set; }
|
|
||||||
public bool IsActive { get; private set; } = true;
|
|
||||||
public DateTime CreatedAt { get; private set; }
|
|
||||||
public DateTime? UpdatedAt { get; private set; }
|
|
||||||
|
|
||||||
// Navigation property
|
|
||||||
public virtual Quiz? Quiz { 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="quizId">ID of the parent quiz</param>
|
|
||||||
/// <param name="questionText">The question text</param>
|
|
||||||
/// <param name="type">The question type</param>
|
|
||||||
/// <param name="correctAnswer">The correct answer</param>
|
|
||||||
/// <param name="points">Points for this question (default 1)</param>
|
|
||||||
/// <param name="difficulty">Difficulty level (1-5, default 3)</param>
|
|
||||||
/// <param name="order">Sort order within the quiz</param>
|
|
||||||
public static QuizQuestion Create(
|
|
||||||
int quizId,
|
|
||||||
string questionText,
|
|
||||||
QuestionType type,
|
|
||||||
string correctAnswer,
|
|
||||||
int points = 1,
|
|
||||||
int difficulty = 3,
|
|
||||||
int order = 1)
|
|
||||||
{
|
|
||||||
if (difficulty < 1 || difficulty > 5)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(difficulty), "Difficulty must be between 1 and 5");
|
|
||||||
|
|
||||||
if (points < 0)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(points), "Points must be positive");
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(questionText))
|
|
||||||
throw new ArgumentException("Question text cannot be empty", nameof(questionText));
|
|
||||||
|
|
||||||
return new QuizQuestion
|
|
||||||
{
|
|
||||||
QuizId = quizId,
|
|
||||||
QuestionText = questionText,
|
|
||||||
Type = type,
|
|
||||||
CorrectAnswer = correctAnswer,
|
|
||||||
Points = points,
|
|
||||||
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 points for this question.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdatePoints(int newPoints)
|
|
||||||
{
|
|
||||||
if (newPoints < 0)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(newPoints), "Points must be positive");
|
|
||||||
|
|
||||||
Points = newPoints;
|
|
||||||
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 quiz.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateQuiz(int newQuizId)
|
|
||||||
{
|
|
||||||
QuizId = newQuizId;
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
namespace GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Domain interface for Mistral AI text generation service.
|
|
||||||
/// This is part of the Domain layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface IMistralService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Generates text from a prompt using Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="prompt">The input prompt</param>
|
|
||||||
/// <param name="model">The model to use (defaults to configured default)</param>
|
|
||||||
/// <param name="temperature">Creativity level (0-1)</param>
|
|
||||||
/// <param name="maxTokens">Maximum tokens to generate</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated text</returns>
|
|
||||||
Task<string> GenerateTextAsync(
|
|
||||||
string prompt,
|
|
||||||
string? model = null,
|
|
||||||
float temperature = 0.7f,
|
|
||||||
int? maxTokens = null,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates text from chat messages using Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="messages">List of chat messages (role, content)</param>
|
|
||||||
/// <param name="model">The model to use</param>
|
|
||||||
/// <param name="temperature">Creativity level (0-1)</param>
|
|
||||||
/// <param name="maxTokens">Maximum tokens to generate</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated text response</returns>
|
|
||||||
Task<string> GenerateChatAsync(
|
|
||||||
IReadOnlyList<(string role, string content)> messages,
|
|
||||||
string? model = null,
|
|
||||||
float temperature = 0.7f,
|
|
||||||
int? maxTokens = null,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a story based on lesson context and vocabulary.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="level">CEFR level (A1, A2, B1, B2, C1)</param>
|
|
||||||
/// <param name="topic">Story topic</param>
|
|
||||||
/// <param name="vocabularyWords">List of vocabulary words to include</param>
|
|
||||||
/// <param name="length">Approximate story length in words</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated story text</returns>
|
|
||||||
Task<string> GenerateStoryAsync(
|
|
||||||
string level,
|
|
||||||
string topic,
|
|
||||||
IReadOnlyList<string> vocabularyWords,
|
|
||||||
int length = 200,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides feedback on user writing.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userText">The text written by the user</param>
|
|
||||||
/// <param name="level">CEFR level for appropriate feedback</param>
|
|
||||||
/// <param name="prompt">Original prompt/exercise</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Feedback with corrections and suggestions</returns>
|
|
||||||
Task<string> GenerateWritingFeedbackAsync(
|
|
||||||
string userText,
|
|
||||||
string level,
|
|
||||||
string? prompt = null,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Mistral API connection.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if connection is successful</returns>
|
|
||||||
Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
@ -133,145 +133,3 @@ 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);
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Quiz-based methods (QuizId)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by quiz ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetByQuizAsync(int quizId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quiz questions by quiz ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetActiveByQuizAsync(int quizId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by quiz with options loaded.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetByQuizWithOptionsAsync(int quizId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a random set of quiz questions for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total points for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
Task<int> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz question with the given order exists in a quiz.
|
|
||||||
/// </summary>
|
|
||||||
Task<bool> OrderExistsInQuizAsync(int quizId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Repository interface for Quiz entities.
|
|
||||||
/// </summary>
|
|
||||||
public interface IQuizRepository : IRepository<Quiz, int>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quizzes by lesson ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<Quiz>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quizzes ordered by lesson and quiz order.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<Quiz>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quizzes by lesson ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<Quiz>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a quiz with its questions and options.
|
|
||||||
/// </summary>
|
|
||||||
Task<Quiz?> GetWithQuestionsAsync(int id, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the first quiz for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
Task<Quiz?> GetFirstByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz exists for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
Task<bool> ExistsByLessonAsync(int lessonId, 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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
using System.IO;
|
|
||||||
|
|
||||||
namespace GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Domain interface for Coqui TTS text-to-speech service.
|
|
||||||
/// This is part of the Domain layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface ITtsService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio from text.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">Text to convert to speech</param>
|
|
||||||
/// <param name="speaker">Speaker ID/voice (optional)</param>
|
|
||||||
/// <param name="language">Language code (default: de)</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio data as bytes</returns>
|
|
||||||
Task<byte[]> GenerateAudioAsync(
|
|
||||||
string text,
|
|
||||||
string? speaker = null,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio and saves to a file.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">Text to convert to speech</param>
|
|
||||||
/// <param name="outputPath">Path to save the audio file</param>
|
|
||||||
/// <param name="speaker">Speaker ID/voice (optional)</param>
|
|
||||||
/// <param name="language">Language code (default: de)</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Path to the generated audio file</returns>
|
|
||||||
Task<string> GenerateAudioToFileAsync(
|
|
||||||
string text,
|
|
||||||
string outputPath,
|
|
||||||
string? speaker = null,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio as a stream.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">Text to convert to speech</param>
|
|
||||||
/// <param name="speaker">Speaker ID/voice (optional)</param>
|
|
||||||
/// <param name="language">Language code (default: de)</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio stream</returns>
|
|
||||||
Task<Stream> GenerateAudioStreamAsync(
|
|
||||||
string text,
|
|
||||||
string? speaker = null,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Coqui TTS model and configuration.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if model is loaded and working</returns>
|
|
||||||
Task<bool> TestModelAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current TTS model information.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Model name and path</returns>
|
|
||||||
Task<(string ModelName, string? ModelPath)> GetModelInfoAsync();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets list of available voices/speakers for the current model.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of available speaker IDs</returns>
|
|
||||||
Task<IReadOnlyList<string>> GetAvailableSpeakersAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cleans up old audio files from storage.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="olderThan">Delete files older than this timespan</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Number of files deleted</returns>
|
|
||||||
Task<int> CleanupOldFilesAsync(
|
|
||||||
TimeSpan olderThan,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
namespace GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Domain interface for Vosk speech recognition service.
|
|
||||||
/// This is part of the Domain layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface IVoskService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from audio bytes.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="audioBytes">Audio data in bytes (WAV format)</param>
|
|
||||||
/// <param name="sampleRate">Sample rate of the audio in Hz</param>
|
|
||||||
/// <param name="hint">Optional hint/phrase to improve recognition accuracy</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Recognized text</returns>
|
|
||||||
Task<string> RecognizeSpeechAsync(
|
|
||||||
byte[] audioBytes,
|
|
||||||
int sampleRate = 16000,
|
|
||||||
string? hint = null,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from an audio file path.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="audioFilePath">Path to the audio file</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Recognized text</returns>
|
|
||||||
Task<string> RecognizeSpeechFromFileAsync(
|
|
||||||
string audioFilePath,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from a stream.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="audioStream">Audio stream</param>
|
|
||||||
/// <param name="sampleRate">Sample rate of the audio in Hz</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Recognized text</returns>
|
|
||||||
Task<string> RecognizeSpeechFromStreamAsync(
|
|
||||||
System.IO.Stream audioStream,
|
|
||||||
int sampleRate = 16000,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Vosk model and configuration.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if model is loaded and working</returns>
|
|
||||||
Task<bool> TestModelAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current Vosk model information.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Model name and path</returns>
|
|
||||||
Task<(string ModelName, string ModelPath)> GetModelInfoAsync();
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
namespace GermanApp.Infrastructure.Configuration;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration settings for Coqui TTS service.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class CoquiConfig
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Path to the Python executable.
|
|
||||||
/// Default: python3
|
|
||||||
/// </summary>
|
|
||||||
public string PythonPath { get; set; } = "python3";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Name of the Coqui TTS model to use.
|
|
||||||
/// Example: tts_models/de/deu/fairseq/vits
|
|
||||||
/// </summary>
|
|
||||||
public string ModelName { get; set; } = "tts_models/de/deu/fairseq/vits";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Path to the TTS Python package/module.
|
|
||||||
/// Default: TTS
|
|
||||||
/// </summary>
|
|
||||||
public string ModulePath { get; set; } = "TTS";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Output audio format.
|
|
||||||
/// Supported: wav, mp3, ogg, flac
|
|
||||||
/// Default: wav
|
|
||||||
/// </summary>
|
|
||||||
public string OutputFormat { get; set; } = "wav";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Output audio sample rate in Hz.
|
|
||||||
/// Default: 22050
|
|
||||||
/// </summary>
|
|
||||||
public int SampleRate { get; set; } = 22050;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Voice speaker ID for the model.
|
|
||||||
/// Default: (empty - uses model default)
|
|
||||||
/// </summary>
|
|
||||||
public string Speaker { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Language code for TTS.
|
|
||||||
/// Default: de
|
|
||||||
/// </summary>
|
|
||||||
public string Language { get; set; } = "de";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maximum text length in characters for a single TTS request.
|
|
||||||
/// Longer texts will be split.
|
|
||||||
/// Default: 500 characters
|
|
||||||
/// </summary>
|
|
||||||
public int MaxTextLength { get; set; } = 500;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Timeout in seconds for TTS processing.
|
|
||||||
/// Default: 60 seconds
|
|
||||||
/// </summary>
|
|
||||||
public int TimeoutSeconds { get; set; } = 60;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Path to store generated audio files.
|
|
||||||
/// Default: /var/audio/tts
|
|
||||||
/// </summary>
|
|
||||||
public string AudioStoragePath { get; set; } = "/var/audio/tts";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to use GPU acceleration if available.
|
|
||||||
/// Default: false
|
|
||||||
/// </summary>
|
|
||||||
public bool UseGPU { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates the configuration.
|
|
||||||
/// </summary>
|
|
||||||
/// <exception cref="ArgumentException">Thrown when configuration is invalid</exception>
|
|
||||||
public void Validate()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(PythonPath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Coqui PythonPath is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ModelName))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Coqui ModelName is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ModulePath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Coqui ModulePath is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(OutputFormat))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Coqui OutputFormat is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
var validFormats = new[] { "wav", "mp3", "ogg", "flac" };
|
|
||||||
if (!validFormats.Contains(OutputFormat.ToLower()))
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
|
||||||
$"Invalid OutputFormat '{OutputFormat}'. Valid formats: {string.Join(", ", validFormats)}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SampleRate <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("SampleRate must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (MaxTextLength <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("MaxTextLength must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TimeoutSeconds <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("TimeoutSeconds must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(AudioStoragePath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("AudioStoragePath is required");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
namespace GermanApp.Infrastructure.Configuration;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration settings for Vosk speech recognition service.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class VoskConfig
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Path to the Python executable.
|
|
||||||
/// Default: python3
|
|
||||||
/// </summary>
|
|
||||||
public string PythonPath { get; set; } = "python3";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Path to the Vosk model directory.
|
|
||||||
/// Example: /models/vosk-model-de-0.22
|
|
||||||
/// </summary>
|
|
||||||
public string ModelPath { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Expected audio sample rate in Hz.
|
|
||||||
/// Vosk models typically use 16000 Hz.
|
|
||||||
/// Default: 16000
|
|
||||||
/// </summary>
|
|
||||||
public int SampleRate { get; set; } = 16000;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maximum audio duration in seconds for speech recognition.
|
|
||||||
/// Default: 60 seconds
|
|
||||||
/// </summary>
|
|
||||||
public int MaxAudioDurationSeconds { get; set; } = 60;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Timeout in seconds for Vosk processing.
|
|
||||||
/// Default: 30 seconds
|
|
||||||
/// </summary>
|
|
||||||
public int TimeoutSeconds { get; set; } = 30;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to enable beam width adjustment for accuracy vs speed tradeoff.
|
|
||||||
/// Higher values = more accurate but slower.
|
|
||||||
/// Default: 16
|
|
||||||
/// </summary>
|
|
||||||
public int BeamWidth { get; set; } = 16;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Path to the Vosk Python package/module.
|
|
||||||
/// Default: vosk
|
|
||||||
/// </summary>
|
|
||||||
public string ModulePath { get; set; } = "vosk";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates the configuration.
|
|
||||||
/// </summary>
|
|
||||||
/// <exception cref="ArgumentException">Thrown when configuration is invalid</exception>
|
|
||||||
public void Validate()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(PythonPath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Vosk PythonPath is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ModelPath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Vosk ModelPath is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SampleRate <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("SampleRate must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (MaxAudioDurationSeconds <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("MaxAudioDurationSeconds must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TimeoutSeconds <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("TimeoutSeconds must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (BeamWidth <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("BeamWidth must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ModulePath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Vosk ModulePath is required");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -19,9 +19,6 @@ 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<Quiz> Quizzes { 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.
|
||||||
|
|
@ -135,83 +132,6 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure Quiz entity
|
|
||||||
modelBuilder.Entity<Quiz>(builder =>
|
|
||||||
{
|
|
||||||
builder.HasKey(q => q.Id);
|
|
||||||
builder.Property(q => q.LessonId).IsRequired();
|
|
||||||
builder.Property(q => q.Title).IsRequired().HasMaxLength(200);
|
|
||||||
builder.Property(q => q.Description).HasMaxLength(2000);
|
|
||||||
builder.Property(q => q.PassingScore).IsRequired().HasDefaultValue(80);
|
|
||||||
builder.Property(q => q.TimeLimitMinutes).HasDefaultValue(0);
|
|
||||||
builder.Property(q => q.IsActive).HasDefaultValue(true);
|
|
||||||
builder.Property(q => q.ShuffleQuestions).HasDefaultValue(true);
|
|
||||||
builder.Property(q => q.CreatedAt).IsRequired();
|
|
||||||
builder.Property(q => q.UpdatedAt).IsRequired(false);
|
|
||||||
|
|
||||||
// Foreign key to Lesson
|
|
||||||
builder.HasOne(q => q.Lesson)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(q => q.LessonId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
// Navigation to questions
|
|
||||||
builder.HasMany(q => q.Questions)
|
|
||||||
.WithOne(qq => qq.Quiz)
|
|
||||||
.HasForeignKey(qq => qq.QuizId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure QuizQuestion entity
|
|
||||||
modelBuilder.Entity<QuizQuestion>(builder =>
|
|
||||||
{
|
|
||||||
builder.HasKey(q => q.Id);
|
|
||||||
builder.Property(q => q.QuizId).IsRequired();
|
|
||||||
builder.Property(q => q.QuestionText).IsRequired().HasMaxLength(2000);
|
|
||||||
builder.Property(q => q.Type).IsRequired();
|
|
||||||
builder.Property(q => q.CorrectAnswer).HasMaxLength(1000);
|
|
||||||
builder.Property(q => q.Points).IsRequired().HasDefaultValue(1);
|
|
||||||
builder.Property(q => q.Difficulty).IsRequired().HasDefaultValue(3);
|
|
||||||
builder.Property(q => q.Order).IsRequired().HasDefaultValue(1);
|
|
||||||
builder.Property(q => q.IsActive).HasDefaultValue(true);
|
|
||||||
builder.Property(q => q.CreatedAt).IsRequired();
|
|
||||||
builder.Property(q => q.UpdatedAt).IsRequired(false);
|
|
||||||
|
|
||||||
// Foreign key to Quiz
|
|
||||||
builder.HasOne(q => q.Quiz)
|
|
||||||
.WithMany(qq => qq.Questions)
|
|
||||||
.HasForeignKey(q => q.QuizId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
// Unique constraint: one question per quiz per order
|
|
||||||
builder.HasIndex(q => new { q.QuizId, q.Order }).IsUnique();
|
|
||||||
|
|
||||||
// Navigation to options
|
|
||||||
builder.HasMany(q => q.Options)
|
|
||||||
.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(
|
||||||
|
|
|
||||||
|
|
@ -1,390 +0,0 @@
|
||||||
// <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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
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,92 +102,6 @@ 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")
|
||||||
|
|
@ -327,28 +241,6 @@ 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)
|
||||||
|
|
@ -376,11 +268,6 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
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.Quiz)
|
|
||||||
.ThenInclude(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.Quiz)
|
|
||||||
.ThenInclude(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.Quiz)
|
|
||||||
.ThenInclude(q => q.Lesson)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.Quiz != null && q.Quiz.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.Quiz)
|
|
||||||
.ThenInclude(q => q.Lesson)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.OrderBy(q => q.Quiz.Lesson.Order)
|
|
||||||
.ThenBy(q => q.Quiz.Id)
|
|
||||||
.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.Quiz)
|
|
||||||
.ThenInclude(q => q.Lesson)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.Quiz != null && q.Quiz.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.Quiz)
|
|
||||||
.ThenInclude(q => q.Lesson)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.Quiz != null && q.Quiz.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.Quiz)
|
|
||||||
.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.Quiz)
|
|
||||||
.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.Quiz != null && q.Quiz.LessonId == lessonId && q.Order == order);
|
|
||||||
|
|
||||||
if (excludeId.HasValue)
|
|
||||||
{
|
|
||||||
query = query.Where(q => q.Id != excludeId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await query.AnyAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Quiz-based methods (QuizId)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.QuizId == quizId)
|
|
||||||
.OrderBy(q => q.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetActiveByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.QuizId == quizId && q.IsActive)
|
|
||||||
.OrderBy(q => q.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetByQuizWithOptionsAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.QuizId == quizId)
|
|
||||||
.OrderBy(q => q.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.QuizId == quizId && q.IsActive)
|
|
||||||
.OrderBy(q => Guid.NewGuid()) // Random ordering
|
|
||||||
.Take(count)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _context.QuizQuestions
|
|
||||||
.Where(q => q.QuizId == quizId && q.IsActive)
|
|
||||||
.Select(q => q.Points)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
return questions.Sum();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> OrderExistsInQuizAsync(int quizId, int order, int? excludeId = null, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var query = _context.QuizQuestions
|
|
||||||
.Where(q => q.QuizId == quizId && q.Order == order);
|
|
||||||
|
|
||||||
if (excludeId.HasValue)
|
|
||||||
{
|
|
||||||
query = query.Where(q => q.Id != excludeId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await query.AnyAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
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 IQuizRepository.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class QuizRepository : IQuizRepository
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _context;
|
|
||||||
|
|
||||||
public QuizRepository(AppDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Quiz?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.FirstOrDefaultAsync(q => q.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Quiz>> GetAllAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Quiz> AddAsync(Quiz entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await _context.Quizzes.AddAsync(entity, cancellationToken);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateAsync(Quiz entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_context.Quizzes.Update(entity);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteAsync(Quiz entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_context.Quizzes.Remove(entity);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.AnyAsync(q => q.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Quiz>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.Where(q => q.LessonId == lessonId)
|
|
||||||
.OrderBy(q => q.Id)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Quiz>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.OrderBy(q => q.Lesson.Order)
|
|
||||||
.ThenBy(q => q.Id)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Quiz>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.Where(q => q.LessonId == lessonId && q.IsActive)
|
|
||||||
.OrderBy(q => q.Id)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Quiz?> GetWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.Include(q => q.Questions.OrderBy(qq => qq.Order))
|
|
||||||
.ThenInclude(qq => qq.Options.OrderBy(o => o.Order))
|
|
||||||
.FirstOrDefaultAsync(q => q.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Quiz?> GetFirstByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.Where(q => q.LessonId == lessonId && q.IsActive)
|
|
||||||
.OrderBy(q => q.Id)
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.AnyAsync(q => q.LessonId == lessonId, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -66,47 +66,7 @@ public static class SeedDataExtension
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
// Seed quiz questions for the lessons
|
Console.WriteLine("Database seeded with levels, admin user, test user, and sample 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.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,426 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Infrastructure service for Coqui TTS text-to-speech.
|
|
||||||
/// Uses Python process to call Coqui TTS library.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class TtsService : ITtsService
|
|
||||||
{
|
|
||||||
private readonly CoquiConfig _config;
|
|
||||||
private readonly ILogger<TtsService> _logger;
|
|
||||||
|
|
||||||
public TtsService(
|
|
||||||
IOptions<CoquiConfig> config,
|
|
||||||
ILogger<TtsService> logger)
|
|
||||||
{
|
|
||||||
_config = config.Value;
|
|
||||||
_logger = logger;
|
|
||||||
|
|
||||||
// Ensure audio storage directory exists
|
|
||||||
Directory.CreateDirectory(_config.AudioStoragePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio from text.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<byte[]> GenerateAudioAsync(
|
|
||||||
string text,
|
|
||||||
string? speaker = null,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
|
||||||
throw new ArgumentException("Text cannot be empty", nameof(text));
|
|
||||||
|
|
||||||
// Split long text into chunks
|
|
||||||
if (text.Length > _config.MaxTextLength)
|
|
||||||
{
|
|
||||||
var chunks = SplitText(text, _config.MaxTextLength);
|
|
||||||
var audioParts = new List<byte[]>();
|
|
||||||
|
|
||||||
foreach (var chunk in chunks)
|
|
||||||
{
|
|
||||||
var audio = await GenerateAudioForChunkAsync(chunk, speaker, language, cancellationToken);
|
|
||||||
audioParts.Add(audio);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine audio bytes
|
|
||||||
return CombineAudioBytes(audioParts);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await GenerateAudioForChunkAsync(text, speaker, language, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio for a single text chunk.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<byte[]> GenerateAudioForChunkAsync(
|
|
||||||
string text,
|
|
||||||
string? speaker,
|
|
||||||
string language,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var tempFile = Path.GetTempFileName() + "." + _config.OutputFormat;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await GenerateAudioToFileInternalAsync(text, tempFile, speaker, language, cancellationToken);
|
|
||||||
return await File.ReadAllBytesAsync(tempFile, cancellationToken);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
try { File.Delete(tempFile); }
|
|
||||||
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp file: {File}", tempFile); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio and saves to a file.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> GenerateAudioToFileAsync(
|
|
||||||
string text,
|
|
||||||
string outputPath,
|
|
||||||
string? speaker = null,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
|
||||||
throw new ArgumentException("Text cannot be empty", nameof(text));
|
|
||||||
|
|
||||||
// Ensure output directory exists
|
|
||||||
var directory = Path.GetDirectoryName(outputPath);
|
|
||||||
if (!string.IsNullOrEmpty(directory))
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
|
|
||||||
await GenerateAudioToFileInternalAsync(text, outputPath, speaker, language, cancellationToken);
|
|
||||||
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Internal method to generate audio to a specific file path.
|
|
||||||
/// </summary>
|
|
||||||
private async Task GenerateAudioToFileInternalAsync(
|
|
||||||
string text,
|
|
||||||
string outputPath,
|
|
||||||
string? speaker,
|
|
||||||
string language,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var script = BuildTtsScript(text, outputPath, speaker, language);
|
|
||||||
var tempScript = Path.GetTempFileName() + ".py";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await File.WriteAllTextAsync(tempScript, script, cancellationToken);
|
|
||||||
|
|
||||||
var result = await ExecutePythonProcessAsync(tempScript, cancellationToken);
|
|
||||||
|
|
||||||
if (result.ExitCode != 0)
|
|
||||||
{
|
|
||||||
_logger.LogError("TTS generation failed. stderr: {Error}", result.StandardError);
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"TTS generation failed: " + result.StandardError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify output file exists
|
|
||||||
if (!File.Exists(outputPath))
|
|
||||||
{
|
|
||||||
throw new FileNotFoundException(
|
|
||||||
"TTS output file not created", outputPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
try { File.Delete(tempScript); }
|
|
||||||
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp script: {File}", tempScript); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio as a stream.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<System.IO.Stream> GenerateAudioStreamAsync(
|
|
||||||
string text,
|
|
||||||
string? speaker = null,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// For streaming, we generate to a temp file and return a FileStream
|
|
||||||
var tempFile = Path.GetTempFileName() + "." + _config.OutputFormat;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await GenerateAudioToFileInternalAsync(text, tempFile, speaker, language, cancellationToken);
|
|
||||||
return new FileStream(tempFile, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Clean up on error
|
|
||||||
try { File.Delete(tempFile); }
|
|
||||||
catch { }
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the Python script for TTS generation.
|
|
||||||
/// </summary>
|
|
||||||
private string BuildTtsScript(string text, string outputPath, string? speaker, string language)
|
|
||||||
{
|
|
||||||
var script = new StringBuilder();
|
|
||||||
script.AppendLine("import sys");
|
|
||||||
script.AppendLine("import json");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Handle import errors
|
|
||||||
script.AppendLine("try:");
|
|
||||||
script.AppendLine(" from TTS.api import TTS");
|
|
||||||
script.AppendLine("except ImportError as e:");
|
|
||||||
script.AppendLine(" print(json.dumps({'error': 'Coqui TTS not installed: ' + str(e)}))");
|
|
||||||
script.AppendLine(" sys.exit(1)");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Load TTS model
|
|
||||||
script.AppendLine($"model_name = '{_config.ModelName}'");
|
|
||||||
script.AppendLine("try:");
|
|
||||||
script.AppendLine(" tts = TTS(model_name=model_name)");
|
|
||||||
script.AppendLine("except Exception as e:");
|
|
||||||
script.AppendLine(" print(json.dumps({'error': 'Failed to load TTS model: ' + str(e)}))");
|
|
||||||
script.AppendLine(" sys.exit(1)");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Configure TTS
|
|
||||||
if (!string.IsNullOrEmpty(speaker))
|
|
||||||
{
|
|
||||||
script.AppendLine($"tts.speaker = '{speaker}'");
|
|
||||||
}
|
|
||||||
script.AppendLine($"tts.language = '{language}'");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Generate and save audio
|
|
||||||
script.AppendLine($"text = '''{text.Replace("'''", "\\'''")}'''");
|
|
||||||
script.AppendLine($"output_path = '{outputPath.Replace("\\", "\\\\")}'");
|
|
||||||
script.AppendLine("tts.tts_to_file(text=text, file_path=output_path)");
|
|
||||||
script.AppendLine("print('success')");
|
|
||||||
|
|
||||||
return script.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Result of Python process execution.
|
|
||||||
/// </summary>
|
|
||||||
private class ProcessResult
|
|
||||||
{
|
|
||||||
public string StandardOutput { get; set; } = string.Empty;
|
|
||||||
public string StandardError { get; set; } = string.Empty;
|
|
||||||
public int ExitCode { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Executes a Python process and returns the result.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<ProcessResult> ExecutePythonProcessAsync(
|
|
||||||
string scriptPath,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var processInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = _config.PythonPath,
|
|
||||||
Arguments = scriptPath,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
StandardOutputEncoding = Encoding.UTF8,
|
|
||||||
StandardErrorEncoding = Encoding.UTF8
|
|
||||||
};
|
|
||||||
|
|
||||||
using var process = new Process { StartInfo = processInfo };
|
|
||||||
|
|
||||||
// Use a timeout for the process
|
|
||||||
var timeoutTask = Task.Delay(_config.TimeoutSeconds * 1000, cancellationToken);
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
|
|
||||||
var outputTask = process.StandardOutput.ReadToEndAsync();
|
|
||||||
var errorTask = process.StandardError.ReadToEndAsync();
|
|
||||||
var exitTask = process.WaitForExitAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Wait for process to complete or timeout
|
|
||||||
var completedTask = await Task.WhenAny(exitTask, timeoutTask);
|
|
||||||
|
|
||||||
if (completedTask == timeoutTask)
|
|
||||||
{
|
|
||||||
// Kill the process if it times out
|
|
||||||
try { process.Kill(); }
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
_logger.LogError("TTS process timed out after {Seconds} seconds", _config.TimeoutSeconds);
|
|
||||||
throw new TimeoutException(
|
|
||||||
$"TTS generation timed out after {_config.TimeoutSeconds} seconds");
|
|
||||||
}
|
|
||||||
|
|
||||||
var output = await outputTask;
|
|
||||||
var error = await errorTask;
|
|
||||||
|
|
||||||
return new ProcessResult
|
|
||||||
{
|
|
||||||
StandardOutput = output,
|
|
||||||
StandardError = error,
|
|
||||||
ExitCode = process.ExitCode
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Splits text into chunks of maximum length.
|
|
||||||
/// </summary>
|
|
||||||
private IReadOnlyList<string> SplitText(string text, int maxLength)
|
|
||||||
{
|
|
||||||
var chunks = new List<string>();
|
|
||||||
var currentChunk = new StringBuilder();
|
|
||||||
|
|
||||||
foreach (var word in text.Split(new[] { ' ', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries))
|
|
||||||
{
|
|
||||||
if (currentChunk.Length + word.Length + 1 <= maxLength)
|
|
||||||
{
|
|
||||||
if (currentChunk.Length > 0)
|
|
||||||
currentChunk.Append(' ');
|
|
||||||
currentChunk.Append(word);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
chunks.Add(currentChunk.ToString());
|
|
||||||
currentChunk.Clear();
|
|
||||||
currentChunk.Append(word);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentChunk.Length > 0)
|
|
||||||
chunks.Add(currentChunk.ToString());
|
|
||||||
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Combines multiple audio byte arrays into a single array.
|
|
||||||
/// Note: This is a simple concatenation. For proper audio merging,
|
|
||||||
/// you would need to use an audio library to handle the WAV header correctly.
|
|
||||||
/// </summary>
|
|
||||||
private byte[] CombineAudioBytes(IReadOnlyList<byte[]> audioParts)
|
|
||||||
{
|
|
||||||
if (audioParts.Count == 1)
|
|
||||||
return audioParts[0];
|
|
||||||
|
|
||||||
// For WAV files, we need to handle the header properly
|
|
||||||
// This is a simplified version that assumes all parts are valid WAV files
|
|
||||||
// In production, you would want to use NAudio or similar to properly merge WAV files
|
|
||||||
|
|
||||||
var totalLength = audioParts.Sum(p => p.Length);
|
|
||||||
var combined = new byte[totalLength];
|
|
||||||
var offset = 0;
|
|
||||||
|
|
||||||
foreach (var part in audioParts)
|
|
||||||
{
|
|
||||||
Buffer.BlockCopy(part, 0, combined, offset, part.Length);
|
|
||||||
offset += part.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return combined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Coqui TTS model and configuration.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> TestModelAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Test with a simple phrase
|
|
||||||
var testText = "Hallo, das ist ein Test.";
|
|
||||||
var testFile = Path.Combine(_config.AudioStoragePath, "test." + _config.OutputFormat);
|
|
||||||
|
|
||||||
await GenerateAudioToFileAsync(testText, testFile, null, "de", cancellationToken);
|
|
||||||
|
|
||||||
// Verify file was created
|
|
||||||
if (File.Exists(testFile))
|
|
||||||
{
|
|
||||||
// Clean up test file
|
|
||||||
File.Delete(testFile);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "TTS model test failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current TTS model information.
|
|
||||||
/// </summary>
|
|
||||||
public virtual Task<(string ModelName, string? ModelPath)> GetModelInfoAsync()
|
|
||||||
{
|
|
||||||
return Task.FromResult<(string ModelName, string? ModelPath)>((_config.ModelName, null));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets list of available voices/speakers for the current model.
|
|
||||||
/// </summary>
|
|
||||||
public virtual Task<IReadOnlyList<string>> GetAvailableSpeakersAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Coqui TTS doesn't always have multiple speakers for all models
|
|
||||||
// This would need to query the model or use a predefined list
|
|
||||||
// For now, return a default list for German models
|
|
||||||
var defaultSpeakers = new List<string> { "default" };
|
|
||||||
return Task.FromResult<IReadOnlyList<string>>(defaultSpeakers);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cleans up old audio files from storage.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<int> CleanupOldFilesAsync(
|
|
||||||
TimeSpan olderThan,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var cutoff = DateTime.UtcNow - olderThan;
|
|
||||||
var deletedCount = 0;
|
|
||||||
|
|
||||||
if (Directory.Exists(_config.AudioStoragePath))
|
|
||||||
{
|
|
||||||
foreach (var file in Directory.GetFiles(_config.AudioStoragePath, "*", SearchOption.AllDirectories))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var fileInfo = new FileInfo(file);
|
|
||||||
if (fileInfo.LastWriteTimeUtc < cutoff)
|
|
||||||
{
|
|
||||||
File.Delete(file);
|
|
||||||
deletedCount++;
|
|
||||||
_logger.LogInformation("Deleted old audio file: {File}", file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to delete audio file: {File}", file);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yield to avoid blocking for long periods
|
|
||||||
await Task.Yield();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return deletedCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Infrastructure service for Vosk speech recognition.
|
|
||||||
/// Uses Python process to call Vosk library.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class VoskService : IVoskService
|
|
||||||
{
|
|
||||||
private readonly VoskConfig _config;
|
|
||||||
private readonly ILogger<VoskService> _logger;
|
|
||||||
|
|
||||||
public VoskService(
|
|
||||||
IOptions<VoskConfig> config,
|
|
||||||
ILogger<VoskService> logger)
|
|
||||||
{
|
|
||||||
_config = config.Value;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from audio bytes.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> RecognizeSpeechAsync(
|
|
||||||
byte[] audioBytes,
|
|
||||||
int sampleRate = 16000,
|
|
||||||
string? hint = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (audioBytes == null || audioBytes.Length == 0)
|
|
||||||
throw new ArgumentException("Audio data cannot be empty", nameof(audioBytes));
|
|
||||||
|
|
||||||
// Validate sample rate
|
|
||||||
if (sampleRate != _config.SampleRate)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
"Audio sample rate ({SampleRate}) does not match expected ({Expected}). " +
|
|
||||||
"Results may be inaccurate.",
|
|
||||||
sampleRate,
|
|
||||||
_config.SampleRate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write audio to temp file
|
|
||||||
var tempFile = Path.GetTempFileName() + ".wav";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await File.WriteAllBytesAsync(tempFile, audioBytes, cancellationToken);
|
|
||||||
|
|
||||||
return await RecognizeFromFileInternalAsync(tempFile, hint, cancellationToken);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// Clean up temp file
|
|
||||||
try { File.Delete(tempFile); }
|
|
||||||
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp file: {File}", tempFile); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from an audio file path.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> RecognizeSpeechFromFileAsync(
|
|
||||||
string audioFilePath,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!File.Exists(audioFilePath))
|
|
||||||
throw new FileNotFoundException("Audio file not found", audioFilePath);
|
|
||||||
|
|
||||||
return await RecognizeFromFileInternalAsync(audioFilePath, null, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from a stream.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> RecognizeSpeechFromStreamAsync(
|
|
||||||
System.IO.Stream audioStream,
|
|
||||||
int sampleRate = 16000,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (audioStream == null || !audioStream.CanRead)
|
|
||||||
throw new ArgumentException("Audio stream must be readable", nameof(audioStream));
|
|
||||||
|
|
||||||
// Read stream to temp file
|
|
||||||
var tempFile = Path.GetTempFileName() + ".wav";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using (var fileStream = File.Create(tempFile))
|
|
||||||
{
|
|
||||||
await audioStream.CopyToAsync(fileStream, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await RecognizeFromFileInternalAsync(tempFile, null, cancellationToken);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
try { File.Delete(tempFile); }
|
|
||||||
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp file: {File}", tempFile); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Internal method to recognize from file with error handling.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<string> RecognizeFromFileInternalAsync(
|
|
||||||
string audioFilePath,
|
|
||||||
string? hint,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// Validate model path
|
|
||||||
if (!Directory.Exists(_config.ModelPath))
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Vosk model directory not found: {_config.ModelPath}");
|
|
||||||
|
|
||||||
// Build Python command
|
|
||||||
var script = BuildRecognitionScript(audioFilePath, hint);
|
|
||||||
var tempScript = Path.GetTempFileName() + ".py";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Write the Python script to a temp file
|
|
||||||
await File.WriteAllTextAsync(tempScript, script, cancellationToken);
|
|
||||||
|
|
||||||
// Execute Python process
|
|
||||||
var result = await ExecutePythonProcessAsync(tempScript, cancellationToken);
|
|
||||||
|
|
||||||
// Parse result
|
|
||||||
var recognizedText = ParseVoskOutput(result.StandardOutput);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(recognizedText))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Vosk returned empty result. stderr: {Error}", result.StandardError);
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Speech recognition failed: " + result.StandardError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return recognizedText.Trim();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
try { File.Delete(tempScript); }
|
|
||||||
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp script: {File}", tempScript); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the Python script for speech recognition.
|
|
||||||
/// </summary>
|
|
||||||
private string BuildRecognitionScript(string audioFilePath, string? hint)
|
|
||||||
{
|
|
||||||
var script = new StringBuilder();
|
|
||||||
script.AppendLine("import sys");
|
|
||||||
script.AppendLine("import json");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Handle potential import errors
|
|
||||||
script.AppendLine("try:");
|
|
||||||
script.AppendLine(" from vosk import Model, KaldiRecognizer, SetLogLevel");
|
|
||||||
script.AppendLine("except ImportError as e:");
|
|
||||||
script.AppendLine(" print(json.dumps({'error': 'Vosk not installed: ' + str(e)}))");
|
|
||||||
script.AppendLine(" sys.exit(1)");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Set log level to suppress warnings
|
|
||||||
script.AppendLine("SetLogLevel(-1)");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Load model
|
|
||||||
script.AppendLine($"model_path = '{_config.ModelPath.Replace("\\", "\\\\")}'");
|
|
||||||
script.AppendLine("try:");
|
|
||||||
script.AppendLine(" model = Model(model_path)");
|
|
||||||
script.AppendLine("except Exception as e:");
|
|
||||||
script.AppendLine(" print(json.dumps({'error': 'Failed to load model: ' + str(e)}))");
|
|
||||||
script.AppendLine(" sys.exit(1)");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Configure recognizer
|
|
||||||
script.AppendLine($"sample_rate = {_config.SampleRate}");
|
|
||||||
script.AppendLine($"beam_width = {_config.BeamWidth}");
|
|
||||||
script.AppendLine("rec = KaldiRecognizer(model, sample_rate)");
|
|
||||||
if (!string.IsNullOrEmpty(hint))
|
|
||||||
{
|
|
||||||
script.AppendLine($"rec.SetGrammar('\"{hint.Replace("'", "\\'")}\"')");
|
|
||||||
}
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Process audio file
|
|
||||||
script.AppendLine($"audio_path = '{audioFilePath.Replace("\\", "\\\\")}'");
|
|
||||||
script.AppendLine("with open(audio_path, 'rb') as f:");
|
|
||||||
script.AppendLine(" while True:");
|
|
||||||
script.AppendLine(" data = f.read(4000)");
|
|
||||||
script.AppendLine(" if len(data) == 0:");
|
|
||||||
script.AppendLine(" break");
|
|
||||||
script.AppendLine(" if rec.AcceptWaveform(data):");
|
|
||||||
script.AppendLine(" pass");
|
|
||||||
script.AppendLine(" else:");
|
|
||||||
script.AppendLine(" pass");
|
|
||||||
script.AppendLine();
|
|
||||||
script.AppendLine("result = rec.FinalResult()");
|
|
||||||
script.AppendLine("result_dict = json.loads(result)");
|
|
||||||
script.AppendLine("print(result_dict.get('text', ''))");
|
|
||||||
|
|
||||||
return script.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Executes a Python process and returns the result.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<(string StandardOutput, string StandardError)> ExecutePythonProcessAsync(
|
|
||||||
string scriptPath,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var processInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = _config.PythonPath,
|
|
||||||
Arguments = scriptPath,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
StandardOutputEncoding = Encoding.UTF8,
|
|
||||||
StandardErrorEncoding = Encoding.UTF8
|
|
||||||
};
|
|
||||||
|
|
||||||
using var process = new Process { StartInfo = processInfo };
|
|
||||||
|
|
||||||
// Use a timeout for the process
|
|
||||||
var timeoutTask = Task.Delay(_config.TimeoutSeconds * 1000, cancellationToken);
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
|
|
||||||
var outputTask = process.StandardOutput.ReadToEndAsync();
|
|
||||||
var errorTask = process.StandardError.ReadToEndAsync();
|
|
||||||
var exitTask = process.WaitForExitAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Wait for process to complete or timeout
|
|
||||||
var completedTask = await Task.WhenAny(exitTask, timeoutTask);
|
|
||||||
|
|
||||||
if (completedTask == timeoutTask)
|
|
||||||
{
|
|
||||||
// Kill the process if it times out
|
|
||||||
try { process.Kill(); }
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
_logger.LogError("Vosk process timed out after {Seconds} seconds", _config.TimeoutSeconds);
|
|
||||||
throw new TimeoutException(
|
|
||||||
$"Vosk speech recognition timed out after {_config.TimeoutSeconds} seconds");
|
|
||||||
}
|
|
||||||
|
|
||||||
var output = await outputTask;
|
|
||||||
var error = await errorTask;
|
|
||||||
|
|
||||||
return (output, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses Vosk JSON output to extract the recognized text.
|
|
||||||
/// </summary>
|
|
||||||
private string ParseVoskOutput(string output)
|
|
||||||
{
|
|
||||||
// Vosk outputs JSON with a "text" field
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(output.Trim());
|
|
||||||
if (doc.RootElement.TryGetProperty("text", out var textElement))
|
|
||||||
{
|
|
||||||
return textElement.GetString() ?? string.Empty;
|
|
||||||
}
|
|
||||||
// Sometimes the output is just the text directly
|
|
||||||
return output.Trim();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// If JSON parsing fails, try to return the output as-is
|
|
||||||
return output.Trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Vosk model and configuration.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> TestModelAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Create a simple test: try to recognize silence or a known phrase
|
|
||||||
// For now, just validate that the model directory exists
|
|
||||||
if (!Directory.Exists(_config.ModelPath))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Try to run a simple test with an empty/short audio
|
|
||||||
// This validates the Python environment and model loading
|
|
||||||
var testAudio = new byte[100]; // Very short audio
|
|
||||||
await RecognizeSpeechAsync(testAudio, _config.SampleRate, null, cancellationToken);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Vosk model test failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current Vosk model information.
|
|
||||||
/// </summary>
|
|
||||||
public virtual Task<(string ModelName, string ModelPath)> GetModelInfoAsync()
|
|
||||||
{
|
|
||||||
var modelName = Path.GetFileName(_config.ModelPath.TrimEnd('/', '\\'));
|
|
||||||
return Task.FromResult((modelName, _config.ModelPath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for Mistral AI text generation.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize]
|
|
||||||
public class MistralController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IMistralService _mistralService;
|
|
||||||
|
|
||||||
public MistralController(IMistralService mistralService)
|
|
||||||
{
|
|
||||||
_mistralService = mistralService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates text from a prompt.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Text generation request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated text</returns>
|
|
||||||
[HttpPost("generate")]
|
|
||||||
public async Task<IActionResult> GenerateText(
|
|
||||||
[FromBody] TextGenerationRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Prompt))
|
|
||||||
return BadRequest("Prompt is required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _mistralService.GenerateTextAsync(
|
|
||||||
request.Prompt,
|
|
||||||
request.Model,
|
|
||||||
request.Temperature,
|
|
||||||
request.MaxTokens,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(new TextGenerationResponse(result));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates text from chat messages.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Chat generation request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated text</returns>
|
|
||||||
[HttpPost("chat")]
|
|
||||||
public async Task<IActionResult> GenerateChat(
|
|
||||||
[FromBody] ChatGenerationRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (request.Messages == null || request.Messages.Count == 0)
|
|
||||||
return BadRequest("Messages are required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _mistralService.GenerateChatAsync(
|
|
||||||
request.Messages,
|
|
||||||
request.Model,
|
|
||||||
request.Temperature,
|
|
||||||
request.MaxTokens,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(new TextGenerationResponse(result));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a story based on lesson context.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Story generation request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated story</returns>
|
|
||||||
[HttpPost("story")]
|
|
||||||
public async Task<IActionResult> GenerateStory(
|
|
||||||
[FromBody] StoryGenerationRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Level))
|
|
||||||
return BadRequest("Level is required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _mistralService.GenerateStoryAsync(
|
|
||||||
request.Level,
|
|
||||||
request.Topic ?? string.Empty,
|
|
||||||
request.VocabularyWords ?? new List<string>(),
|
|
||||||
request.Length,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(new StoryGenerationResponse(result));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides feedback on user writing.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Writing feedback request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated feedback</returns>
|
|
||||||
[HttpPost("writing-feedback")]
|
|
||||||
public async Task<IActionResult> GenerateWritingFeedback(
|
|
||||||
[FromBody] WritingFeedbackRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.UserText))
|
|
||||||
return BadRequest("User text is required");
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Level))
|
|
||||||
return BadRequest("Level is required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _mistralService.GenerateWritingFeedbackAsync(
|
|
||||||
request.UserText,
|
|
||||||
request.Level,
|
|
||||||
request.Prompt,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(new WritingFeedbackResponse(result));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Mistral API connection.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Health check result</returns>
|
|
||||||
[HttpGet("health")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> HealthCheck(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isHealthy = await _mistralService.TestConnectionAsync(cancellationToken);
|
|
||||||
return Ok(new { Healthy = isHealthy });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for text generation.
|
|
||||||
/// </summary>
|
|
||||||
public record TextGenerationRequest(
|
|
||||||
string Prompt,
|
|
||||||
string? Model = null,
|
|
||||||
float Temperature = 0.7f,
|
|
||||||
int? MaxTokens = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for chat generation.
|
|
||||||
/// </summary>
|
|
||||||
public record ChatGenerationRequest(
|
|
||||||
IReadOnlyList<(string Role, string Content)> Messages,
|
|
||||||
string? Model = null,
|
|
||||||
float Temperature = 0.7f,
|
|
||||||
int? MaxTokens = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for story generation.
|
|
||||||
/// </summary>
|
|
||||||
public record StoryGenerationRequest(
|
|
||||||
string Level,
|
|
||||||
string? Topic = null,
|
|
||||||
IReadOnlyList<string>? VocabularyWords = null,
|
|
||||||
int Length = 200);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for writing feedback.
|
|
||||||
/// </summary>
|
|
||||||
public record WritingFeedbackRequest(
|
|
||||||
string UserText,
|
|
||||||
string Level,
|
|
||||||
string? Prompt = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for text generation.
|
|
||||||
/// </summary>
|
|
||||||
public record TextGenerationResponse(string Text);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for story generation.
|
|
||||||
/// </summary>
|
|
||||||
public record StoryGenerationResponse(string Story);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for writing feedback.
|
|
||||||
/// </summary>
|
|
||||||
public record WritingFeedbackResponse(string Feedback);
|
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
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 quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <returns>List of quiz questions for the specified quiz</returns>
|
|
||||||
[HttpGet("by-quiz/{quizId}")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all active quiz questions for a specific lesson (legacy - uses first quiz).
|
|
||||||
/// </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 quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <param name="count">Number of questions to return</param>
|
|
||||||
/// <returns>List of random quiz questions for the quiz</returns>
|
|
||||||
[HttpGet("random/{quizId}/{count}")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a random set of quiz questions for a lesson (legacy - uses first quiz).
|
|
||||||
/// </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/by-lesson/{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 (requires QuizId)</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 (requires QuizId)</param>
|
|
||||||
/// <returns>The quiz result</returns>
|
|
||||||
[HttpPost("submit/{userId}")]
|
|
||||||
[Authorize]
|
|
||||||
public async Task<IActionResult> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Convert legacy DTO to new format
|
|
||||||
var newDto = new SubmitQuizDto(dto.QuizId, dto.Answers);
|
|
||||||
var result = await _quizQuestionService.SubmitQuizAnswersForQuizAsync(userId, newDto, cancellationToken);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,344 +0,0 @@
|
||||||
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 quizzes.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize]
|
|
||||||
public class QuizzesController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly QuizService _quizService;
|
|
||||||
private readonly QuizQuestionService _quizQuestionService;
|
|
||||||
|
|
||||||
public QuizzesController(
|
|
||||||
QuizService quizService,
|
|
||||||
QuizQuestionService quizQuestionService)
|
|
||||||
{
|
|
||||||
_quizService = quizService;
|
|
||||||
_quizQuestionService = quizQuestionService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quizzes.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of all quizzes</returns>
|
|
||||||
[HttpGet]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetAllQuizzesAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizService.GetAllQuizzesAsync(cancellationToken);
|
|
||||||
return Ok(quizzes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quizzes as list items (summary).
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of quiz summary items</returns>
|
|
||||||
[HttpGet("list")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetAllQuizListItemsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizService.GetAllQuizListItemsAsync(cancellationToken);
|
|
||||||
return Ok(quizzes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific quiz by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <returns>The quiz with the specified ID</returns>
|
|
||||||
[HttpGet("{id}")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.GetQuizByIdAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a quiz with its questions by ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <returns>The quiz with questions</returns>
|
|
||||||
[HttpGet("{id}/with-questions")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.GetQuizWithQuestionsAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quizzes for a specific lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>List of quizzes for the specified lesson</returns>
|
|
||||||
[HttpGet("by-lesson/{lessonId}")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizService.GetQuizzesByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return Ok(quizzes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quizzes for a specific lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>List of active quizzes for the specified lesson</returns>
|
|
||||||
[HttpGet("active/by-lesson/{lessonId}")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizService.GetActiveQuizzesByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return Ok(quizzes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the first quiz for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>The first quiz for the lesson</returns>
|
|
||||||
[HttpGet("first/by-lesson/{lessonId}")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.GetFirstQuizByLessonAsync(lessonId, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all active quizzes.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of active quizzes</returns>
|
|
||||||
[HttpGet("active")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetActiveQuizzesAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizService.GetActiveQuizzesAsync(cancellationToken);
|
|
||||||
return Ok(quizzes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="dto">The quiz data</param>
|
|
||||||
/// <returns>The created quiz</returns>
|
|
||||||
[HttpPost]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> CreateQuizAsync(CreateQuizDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.CreateQuizAsync(dto, cancellationToken);
|
|
||||||
return CreatedAtAction(nameof(GetQuizByIdAsync), new { id = quiz.Id }, quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <param name="dto">The updated quiz data</param>
|
|
||||||
/// <returns>The updated quiz</returns>
|
|
||||||
[HttpPut("{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> UpdateQuizAsync(int id, UpdateQuizDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.UpdateQuizAsync(id, dto, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a quiz by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <returns>No content on success</returns>
|
|
||||||
[HttpDelete("{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> DeleteQuizAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var result = await _quizService.DeleteQuizAsync(id, cancellationToken);
|
|
||||||
if (!result)
|
|
||||||
return NotFound();
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Activates a quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <returns>The activated quiz</returns>
|
|
||||||
[HttpPost("{id}/activate")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> ActivateQuizAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.ActivateQuizAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deactivates a quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <returns>The deactivated quiz</returns>
|
|
||||||
[HttpPost("{id}/deactivate")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> DeactivateQuizAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.DeactivateQuizAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz exists by ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <returns>True if the quiz exists</returns>
|
|
||||||
[HttpGet("exists/{id}")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var exists = await _quizService.ExistsAsync(id, cancellationToken);
|
|
||||||
return Ok(new { exists });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz exists for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>True if a quiz exists for the lesson</returns>
|
|
||||||
[HttpGet("exists/by-lesson/{lessonId}")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var exists = await _quizService.ExistsByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return Ok(new { lessonId, exists });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total number of quizzes.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>The total count of quizzes</returns>
|
|
||||||
[HttpGet("count")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetTotalQuizCountAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var count = await _quizService.GetTotalQuizCountAsync(cancellationToken);
|
|
||||||
return Ok(new { count });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of quizzes for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>The count of quizzes for the lesson</returns>
|
|
||||||
[HttpGet("count/by-lesson/{lessonId}")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var count = await _quizService.GetQuizCountByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return Ok(new { lessonId, count });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quizzes by passing score range.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="minScore">Minimum passing score</param>
|
|
||||||
/// <param name="maxScore">Maximum passing score</param>
|
|
||||||
/// <returns>List of quizzes in the score range</returns>
|
|
||||||
[HttpGet("by-passing-score/{minScore}/{maxScore}")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizService.GetQuizzesByPassingScoreRangeAsync(minScore, maxScore, cancellationToken);
|
|
||||||
return Ok(quizzes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions for a specific quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <returns>List of quiz questions for the quiz</returns>
|
|
||||||
[HttpGet("{quizId}/questions")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quiz questions for a specific quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <returns>List of active quiz questions for the quiz</returns>
|
|
||||||
[HttpGet("{quizId}/questions/active")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets random quiz questions for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <param name="count">Number of questions to return</param>
|
|
||||||
/// <returns>List of random quiz questions</returns>
|
|
||||||
[HttpGet("{quizId}/questions/random/{count}")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Submits quiz answers and returns the result.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <param name="dto">The quiz answers submission</param>
|
|
||||||
/// <returns>The quiz result</returns>
|
|
||||||
[HttpPost("submit/{userId}/{quizId}")]
|
|
||||||
[Authorize]
|
|
||||||
public async Task<IActionResult> SubmitQuizAnswersAsync(int userId, int quizId, SubmitQuizDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Update the submission to use the correct quizId
|
|
||||||
var submission = new SubmitQuizDto(quizId, dto.Answers);
|
|
||||||
var result = await _quizQuestionService.SubmitQuizAnswersForQuizAsync(userId, submission, cancellationToken);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total points for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <returns>The total points for the quiz</returns>
|
|
||||||
[HttpGet("{quizId}/total-points")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var points = await _quizQuestionService.GetTotalPointsForQuizAsync(quizId, cancellationToken);
|
|
||||||
return Ok(new { quizId, points });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for speech recognition using Vosk.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize]
|
|
||||||
public class SpeechController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IVoskService _voskService;
|
|
||||||
|
|
||||||
public SpeechController(IVoskService voskService)
|
|
||||||
{
|
|
||||||
_voskService = voskService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from audio bytes.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Audio recognition request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Recognized text</returns>
|
|
||||||
[HttpPost("recognize")]
|
|
||||||
[RequestSizeLimit(10_000_000)] // 10MB limit
|
|
||||||
[RequestFormLimits(MultipartBodyLengthLimit = 10_000_000)]
|
|
||||||
public async Task<IActionResult> RecognizeSpeech(
|
|
||||||
[FromForm] SpeechRecognitionRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (request?.Audio == null || request.Audio.Length == 0)
|
|
||||||
return BadRequest("Audio data is required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _voskService.RecognizeSpeechAsync(
|
|
||||||
request.Audio,
|
|
||||||
request.SampleRate,
|
|
||||||
request.Hint,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(new SpeechRecognitionResponse(result));
|
|
||||||
}
|
|
||||||
catch (TimeoutException ex)
|
|
||||||
{
|
|
||||||
return StatusCode(504, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the speech recognition service.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Health check result</returns>
|
|
||||||
[HttpGet("health")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> HealthCheck(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isHealthy = await _voskService.TestModelAsync(cancellationToken);
|
|
||||||
return Ok(new { Healthy = isHealthy });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets information about the current Vosk model.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Model information</returns>
|
|
||||||
[HttpGet("model-info")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetModelInfo(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var (modelName, modelPath) = await _voskService.GetModelInfoAsync();
|
|
||||||
return Ok(new { ModelName = modelName, ModelPath = modelPath });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for speech recognition.
|
|
||||||
/// </summary>
|
|
||||||
public record SpeechRecognitionRequest(
|
|
||||||
byte[] Audio,
|
|
||||||
int SampleRate = 16000,
|
|
||||||
string? Hint = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for speech recognition.
|
|
||||||
/// </summary>
|
|
||||||
public record SpeechRecognitionResponse(string Text);
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for text-to-speech using Coqui TTS.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize]
|
|
||||||
public class TtsController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ITtsService _ttsService;
|
|
||||||
|
|
||||||
public TtsController(ITtsService ttsService)
|
|
||||||
{
|
|
||||||
_ttsService = ttsService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio from text.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">TTS generation request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio file as bytes</returns>
|
|
||||||
[HttpPost("generate")]
|
|
||||||
[RequestSizeLimit(5_000_000)] // 5MB limit for response
|
|
||||||
public async Task<IActionResult> GenerateAudio(
|
|
||||||
[FromBody] TtsGenerationRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Text))
|
|
||||||
return BadRequest("Text is required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var audioBytes = await _ttsService.GenerateAudioAsync(
|
|
||||||
request.Text,
|
|
||||||
request.Speaker,
|
|
||||||
request.Language,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// Return audio as WAV or specified format
|
|
||||||
var contentType = GetContentType(request.Format);
|
|
||||||
return File(audioBytes, contentType);
|
|
||||||
}
|
|
||||||
catch (TimeoutException ex)
|
|
||||||
{
|
|
||||||
return StatusCode(504, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio and returns a file URL.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">TTS generation request with filename</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio file URL</returns>
|
|
||||||
[HttpPost("generate-file")]
|
|
||||||
public async Task<IActionResult> GenerateAudioToFile(
|
|
||||||
[FromBody] TtsFileGenerationRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Text))
|
|
||||||
return BadRequest("Text is required");
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Filename))
|
|
||||||
return BadRequest("Filename is required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var outputPath = Path.Combine("audio", "tts", request.Filename);
|
|
||||||
await _ttsService.GenerateAudioToFileAsync(
|
|
||||||
request.Text,
|
|
||||||
outputPath,
|
|
||||||
request.Speaker,
|
|
||||||
request.Language,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(new TtsFileResponse(
|
|
||||||
Url: $"/audio/tts/{request.Filename}",
|
|
||||||
Filename: request.Filename));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the TTS service.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Health check result</returns>
|
|
||||||
[HttpGet("health")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> HealthCheck(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isHealthy = await _ttsService.TestModelAsync(cancellationToken);
|
|
||||||
return Ok(new { Healthy = isHealthy });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets information about the current TTS model.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Model information</returns>
|
|
||||||
[HttpGet("model-info")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetModelInfo(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var (modelName, modelPath) = await _ttsService.GetModelInfoAsync();
|
|
||||||
return Ok(new { ModelName = modelName, ModelPath = modelPath });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets list of available voices/speakers.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of speaker IDs</returns>
|
|
||||||
[HttpGet("speakers")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetSpeakers(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var speakers = await _ttsService.GetAvailableSpeakersAsync(cancellationToken);
|
|
||||||
return Ok(new { Speakers = speakers });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the content type for the specified audio format.
|
|
||||||
/// </summary>
|
|
||||||
private string GetContentType(string? format)
|
|
||||||
{
|
|
||||||
format = format?.ToLower() ?? "wav";
|
|
||||||
return format switch
|
|
||||||
{
|
|
||||||
"wav" => "audio/wav",
|
|
||||||
"mp3" => "audio/mpeg",
|
|
||||||
"ogg" => "audio/ogg",
|
|
||||||
"flac" => "audio/flac",
|
|
||||||
_ => "audio/wav"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for TTS generation.
|
|
||||||
/// </summary>
|
|
||||||
public record TtsGenerationRequest(
|
|
||||||
string Text,
|
|
||||||
string? Speaker = null,
|
|
||||||
string Language = "de",
|
|
||||||
string? Format = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for TTS file generation.
|
|
||||||
/// </summary>
|
|
||||||
public record TtsFileGenerationRequest(
|
|
||||||
string Text,
|
|
||||||
string Filename,
|
|
||||||
string? Speaker = null,
|
|
||||||
string Language = "de");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for TTS file generation.
|
|
||||||
/// </summary>
|
|
||||||
public record TtsFileResponse(
|
|
||||||
string Url,
|
|
||||||
string Filename);
|
|
||||||
|
|
@ -134,9 +134,6 @@ 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<IQuizRepository, QuizRepository>();
|
|
||||||
builder.Services.AddScoped<IQuizQuestionRepository, QuizQuestionRepository>();
|
|
||||||
builder.Services.AddScoped<IQuizOptionRepository, QuizOptionRepository>();
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// INFRASTRUCTURE LAYER - AI Services
|
// INFRASTRUCTURE LAYER - AI Services
|
||||||
|
|
@ -151,8 +148,6 @@ try
|
||||||
// Add HttpClient for Mistral API
|
// Add HttpClient for Mistral API
|
||||||
builder.Services.AddHttpClient("MistralClient");
|
builder.Services.AddHttpClient("MistralClient");
|
||||||
|
|
||||||
// APPLICATION LAYER - Use Cases & Services
|
|
||||||
// ============================================
|
|
||||||
// Register Mistral Connector
|
// Register Mistral Connector
|
||||||
builder.Services.AddScoped<IMistralConnector>(provider =>
|
builder.Services.AddScoped<IMistralConnector>(provider =>
|
||||||
{
|
{
|
||||||
|
|
@ -164,19 +159,8 @@ try
|
||||||
return new MistralConnector(httpClient, config, logger, cache);
|
return new MistralConnector(httpClient, config, logger, cache);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add AI service configurations
|
|
||||||
builder.Services.Configure<VoskConfig>(builder.Configuration.GetSection("Vosk"));
|
|
||||||
builder.Services.Configure<CoquiConfig>(builder.Configuration.GetSection("Coqui"));
|
|
||||||
|
|
||||||
// Register AI services (Infrastructure implementations of Domain interfaces)
|
|
||||||
builder.Services.AddScoped<IMistralService, MistralService>();
|
|
||||||
builder.Services.AddScoped<IVoskService, VoskService>();
|
|
||||||
builder.Services.AddScoped<ITtsService, TtsService>();
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// APPLICATION LAYER - Use Cases & Services
|
// APPLICATION LAYER - Use Cases & Services
|
||||||
// ========================================================================================
|
|
||||||
// APPLICATION LAYER - Use Cases & Services
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// Register command handlers
|
// Register command handlers
|
||||||
|
|
@ -185,19 +169,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<QuizService>();
|
|
||||||
builder.Services.AddScoped<QuizQuestionService>();
|
|
||||||
builder.Services.AddScoped<LessonUnlockService>();
|
|
||||||
builder.Services.AddScoped<LevelCompletionCalculator>();
|
|
||||||
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
||||||
|
|
||||||
// Note: QuizQuestionService requires IQuizRepository, ILessonRepository, and ProgressService which are registered above
|
|
||||||
|
|
||||||
// Register Quiz command handlers
|
|
||||||
builder.Services.AddScoped<ICommandHandler<CreateQuizCommand, QuizDto>, CreateQuizCommandHandler>();
|
|
||||||
builder.Services.AddScoped<ICommandHandler<UpdateQuizCommand, QuizDto?>, UpdateQuizCommandHandler>();
|
|
||||||
builder.Services.AddScoped<ICommandHandler<DeleteQuizCommand, bool>, DeleteQuizCommandHandler>();
|
|
||||||
builder.Services.AddScoped<ICommandHandler<GetQuizWithQuestionsCommand, QuizWithQuestionsDto?>, GetQuizWithQuestionsCommandHandler>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|
@ -232,20 +204,10 @@ try
|
||||||
app.MapLessonsEndpoints();
|
app.MapLessonsEndpoints();
|
||||||
|
|
||||||
// Seed database with initial data
|
// Seed database with initial data
|
||||||
// Run migrations and seed in Development, or just migrations in Production/Docker
|
if (app.Environment.IsDevelopment())
|
||||||
// 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", () =>
|
||||||
|
|
@ -268,36 +230,6 @@ 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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
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<IQuizRepository> _mockQuizRepo;
|
|
||||||
private Mock<ILessonRepository> _mockLessonRepo;
|
|
||||||
private Mock<ProgressService> _mockProgressService;
|
|
||||||
private QuizQuestionService _service;
|
|
||||||
|
|
||||||
[TestInitialize]
|
|
||||||
public void Setup()
|
|
||||||
{
|
|
||||||
_mockQuizQuestionRepo = new Mock<IQuizQuestionRepository>();
|
|
||||||
_mockQuizOptionRepo = new Mock<IQuizOptionRepository>();
|
|
||||||
_mockQuizRepo = new Mock<IQuizRepository>();
|
|
||||||
_mockLessonRepo = new Mock<ILessonRepository>();
|
|
||||||
_mockProgressService = new Mock<ProgressService>(null!, null!, null!);
|
|
||||||
_service = new QuizQuestionService(
|
|
||||||
_mockQuizQuestionRepo.Object,
|
|
||||||
_mockQuizOptionRepo.Object,
|
|
||||||
_mockQuizRepo.Object,
|
|
||||||
_mockLessonRepo.Object,
|
|
||||||
_mockProgressService.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commented out - requires QuizQuestion entity with specific ID setup
|
|
||||||
// [TestMethod]
|
|
||||||
// public async Task CreateQuizQuestionAsync_WithValidDtoAndValidLesson_CreatesQuestion()
|
|
||||||
// {
|
|
||||||
// ...
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Commented out - requires QuizQuestion entity with specific ID setup
|
|
||||||
// [TestMethod]
|
|
||||||
// public async Task UpdateQuizQuestionAsync_WithExistingQuestion_UpdatesQuestion()
|
|
||||||
// {
|
|
||||||
// ...
|
|
||||||
// }
|
|
||||||
|
|
||||||
[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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// These tests require creating Quiz and QuizQuestion entities with specific IDs
|
|
||||||
// which is not straightforward due to private setters.
|
|
||||||
// For now, these tests are commented out as they are not critical to Lesson Management feature.
|
|
||||||
// They can be revisited when Quiz system is fully implemented.
|
|
||||||
|
|
||||||
// [TestMethod]
|
|
||||||
// public async Task SubmitQuizAnswersAsync_WithCorrectAnswers_ReturnsPassedResult()
|
|
||||||
// {
|
|
||||||
// ...
|
|
||||||
// }
|
|
||||||
|
|
||||||
// [TestMethod]
|
|
||||||
// public async Task SubmitQuizAnswersAsync_WithIncorrectAnswers_ReturnsFailedResult()
|
|
||||||
// {
|
|
||||||
// ...
|
|
||||||
// }
|
|
||||||
|
|
||||||
[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, 3),
|
|
||||||
QuizQuestion.Create(1, "Hard", QuestionType.MultipleChoice, "A3", 5, 5)
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(questions);
|
|
||||||
|
|
||||||
var result = await _service.GetDifficultyDistributionAsync(1);
|
|
||||||
|
|
||||||
Assert.AreEqual(1, result[1]); // Easy (difficulty 1)
|
|
||||||
Assert.AreEqual(0, result[2]); // No questions with difficulty 2
|
|
||||||
Assert.AreEqual(1, result[3]); // Medium (difficulty 3)
|
|
||||||
Assert.AreEqual(0, result[4]); // No questions with difficulty 4
|
|
||||||
Assert.AreEqual(1, result[5]); // Hard (difficulty 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -28,7 +28,6 @@ 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}
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,8 @@ Implement the core backend functionality, including lesson management, AI servic
|
||||||
### Features
|
### Features
|
||||||
| # | Feature | Description | Hours | Status | Dependencies |
|
| # | Feature | Description | Hours | Status | Dependencies |
|
||||||
|---|---------|-------------|-------|--------|--------------|
|
|---|---------|-------------|-------|--------|--------------|
|
||||||
| 2.1 | [Lesson Management](features/lesson-management.md) | Lessons, levels, progress tracking | 10-16h | ✅ Complete | Phase 1 |
|
| 2.1 | [Lesson Management](features/lesson-management.md) | Lessons, levels, progress tracking | 10-16h | 🚀 In Progress (Phase 1 & 2 ✅) | Phase 1 |
|
||||||
| 2.2 | [AI Services](features/ai-services.md) | Mistral, Vosk, Coqui TTS integration | 10-16h | 🚀 In Progress | Phase 1 |
|
| 2.2 | [AI Services](features/ai-services.md) | Mistral, Vosk, Coqui TTS integration | 10-16h | ⏳ Planned | Phase 1 |
|
||||||
| 2.3 | [Vocabulary System](features/vocabulary-system.md) | Word storage, audio, import | 8-12h | ⏳ Planned | Phase 1, 2.1 |
|
| 2.3 | [Vocabulary System](features/vocabulary-system.md) | Word storage, audio, import | 8-12h | ⏳ Planned | Phase 1, 2.1 |
|
||||||
| 2.4 | [Quiz System](features/quiz-system.md) | Multiple question types, scoring | 6-10h | ⏳ Planned | Phase 1, 2.1 |
|
| 2.4 | [Quiz System](features/quiz-system.md) | Multiple question types, scoring | 6-10h | ⏳ Planned | Phase 1, 2.1 |
|
||||||
|
|
||||||
|
|
@ -344,18 +344,17 @@ Week 9-10: Testing, Polish, Bug Fixes (20h)
|
||||||
|
|
||||||
### Milestone 2: Core Backend Complete (End of Week 4)
|
### Milestone 2: Core Backend Complete (End of Week 4)
|
||||||
**Success Metrics:**
|
**Success Metrics:**
|
||||||
- [x] Lesson management works
|
- [ ] Lesson management works
|
||||||
- [x] AI services implemented (Mistral, Vosk, Coqui TTS) - ready for functional testing
|
- [ ] AI services integrate successfully
|
||||||
- [ ] Vocabulary system works with audio
|
- [ ] Vocabulary system works with audio
|
||||||
- [ ] Quiz system works with all question types
|
- [ ] Quiz system works with all question types
|
||||||
- [x] Progress tracking updates correctly (when quiz passed)
|
- [ ] Progress tracking updates correctly
|
||||||
- [x] Can start any Phase 3 feature
|
- [ ] Can start any Phase 3 feature
|
||||||
|
|
||||||
**Exit Criteria:**
|
**Exit Criteria:**
|
||||||
- Most Phase 2 acceptance criteria met
|
- All Phase 2 acceptance criteria met
|
||||||
- All Phase 2 tests passing (296 tests: 148 unit + 148 integration)
|
- All Phase 2 tests passing
|
||||||
- Lesson Management documentation complete
|
- All Phase 2 documentation complete
|
||||||
- AI Services documentation updated
|
|
||||||
|
|
||||||
### Milestone 3: Content & Features Complete (End of Week 6)
|
### Milestone 3: Content & Features Complete (End of Week 6)
|
||||||
**Success Metrics:**
|
**Success Metrics:**
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
# Feature: AI Services Integration
|
# Feature: AI Services Integration
|
||||||
|
|
||||||
> **Status**: 🚀 In Progress
|
> **Status**: 🚀 In Progress
|
||||||
> **📊 Current Progress**: Phase 0-4 ✅ Complete (Configuration, Interfaces, Services, Controllers), Phase 5 ⏳ Pending (Higher-level services)
|
|
||||||
> **Priority**: High
|
> **Priority**: High
|
||||||
> **Complexity**: High
|
> **Complexity**: High
|
||||||
> **Estimate**: 12-18 hours
|
> **Estimate**: 12-18 hours
|
||||||
|
|
@ -200,14 +199,14 @@ For production deployment via Woodpecker:
|
||||||
- Resilient to API failures (retry, rate limiting, circuit breaker, caching)
|
- Resilient to API failures (retry, rate limiting, circuit breaker, caching)
|
||||||
- Testable with mocked HTTP client
|
- Testable with mocked HTTP client
|
||||||
|
|
||||||
### Phase 1: Configuration ### Phase 1: Configuration & Interfaces (2 hours) Interfaces (2 hours) ✅
|
### Phase 1: Configuration & Interfaces (2 hours)
|
||||||
- [x] Add AI configuration section to appsettings.json
|
- [ ] Add AI configuration section to appsettings.json
|
||||||
- [ ] Create configuration classes (MistralConfig, VoskConfig, CoquiConfig)
|
- [ ] Create configuration classes (MistralConfig, VoskConfig, CoquiConfig)
|
||||||
- [ ] Define service interfaces (IMistralService, IVoskService, ITtsService)
|
- [ ] Define service interfaces (IMistralService, IVoskService, ITtsService)
|
||||||
- [ ] Register services in Program.cs
|
- [ ] Register services in Program.cs
|
||||||
- [ ] Set up configuration validation
|
- [ ] Set up configuration validation
|
||||||
|
|
||||||
### Phase 2: Mistral-Medium Integration (2-3 hours) ✅
|
### Phase 2: Mistral-Medium Integration (2-3 hours)
|
||||||
- [ ] Create MistralService implementation
|
- [ ] Create MistralService implementation
|
||||||
- [ ] Implement Mistral API client
|
- [ ] Implement Mistral API client
|
||||||
- [ ] Create request/response models
|
- [ ] Create request/response models
|
||||||
|
|
@ -216,16 +215,16 @@ For production deployment via Woodpecker:
|
||||||
- [ ] Add response caching for similar prompts
|
- [ ] Add response caching for similar prompts
|
||||||
- [ ] Create prompt templates for different use cases
|
- [ ] Create prompt templates for different use cases
|
||||||
|
|
||||||
### Phase 3: Vosk Speech Recognition (2-3 hours) ✅
|
### Phase 3: Vosk Speech Recognition (2-3 hours)
|
||||||
- [ ] Create VoskService implementation
|
- [ ] Create VoskService implementation
|
||||||
- [ ] Set up Vosk Python environment
|
- [ ] Set up Vosk Python environment
|
||||||
- [ ] Download and configure German model (vosk-model-de-0.22)
|
- [ ] Download and configure German model (vosk-model-de-0.22)
|
||||||
- [ ] Implement audio processing
|
- [ ] Implement audio processing
|
||||||
- [ ] Handle different audio formats
|
- [ ] Handle different audio formats
|
||||||
- [ ] Add error handling for recognition failures
|
- [ ] Add error handling for recognition failures
|
||||||
- [x] Create /api/speech/recognize endpoint
|
- [ ] Create /api/speech/recognize endpoint
|
||||||
|
|
||||||
### Phase 4: Coqui TTS Integration (2-3 hours) ✅
|
### Phase 4: Coqui TTS Integration (2-3 hours)
|
||||||
- [ ] Create TtsService implementation
|
- [ ] Create TtsService implementation
|
||||||
- [ ] Set up Coqui TTS Python environment
|
- [ ] Set up Coqui TTS Python environment
|
||||||
- [ ] Download and configure German model
|
- [ ] Download and configure German model
|
||||||
|
|
@ -245,10 +244,10 @@ For production deployment via Woodpecker:
|
||||||
### Milestones
|
### Milestones
|
||||||
| Milestone | Date | Status |
|
| Milestone | Date | Status |
|
||||||
|-----------|------|--------|
|
|-----------|------|--------|
|
||||||
| Configuration & Interfaces | - | ✅ |
|
| Configuration & Interfaces | - | ⏳ |
|
||||||
| Mistral Integration | - | ✅ |
|
| Mistral Integration | - | ⏳ |
|
||||||
| Vosk Integration | - | ✅ |
|
| Vosk Integration | - | ⏳ |
|
||||||
| Coqui TTS Integration | - | ✅ |
|
| Coqui TTS Integration | - | ⏳ |
|
||||||
| Service Integration | - | ⏳ |
|
| Service Integration | - | ⏳ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -275,44 +274,43 @@ For production deployment via Woodpecker:
|
||||||
|
|
||||||
### Backend - Configuration
|
### Backend - Configuration
|
||||||
- [x] Create Configuration/MistralConfig.cs
|
- [x] Create Configuration/MistralConfig.cs
|
||||||
- [ ] Add Mistral settings to appsettings.json (MistralConfig already registered)
|
- [ ] Add Mistral settings to appsettings.json
|
||||||
- [ ] Add Vosk settings to appsettings.json (VoskConfig registered, needs model path)
|
- [ ] Add Vosk settings to appsettings.json
|
||||||
- [ ] Add Coqui settings to appsettings.json (CoquiConfig registered, needs model name)
|
- [ ] Add Coqui settings to appsettings.json
|
||||||
- [x] Create Configuration/VoskConfig.cs
|
- [ ] Create Configuration/VoskConfig.cs
|
||||||
- [x] Create Configuration/CoquiConfig.cs
|
- [ ] Create Configuration/CoquiConfig.cs
|
||||||
- [x] Register Mistral Connector in Program.cs
|
- [x] Register Mistral Connector in Program.cs
|
||||||
- [x] Register all AI services in Program.cs
|
- [ ] Register all AI services in Program.cs
|
||||||
- [ ] Add health checks for AI services
|
- [ ] Add health checks for AI services
|
||||||
|
|
||||||
### Backend - Mistral Service
|
### Backend - Mistral Service
|
||||||
- [x] Create `Domain/Interfaces/IMistralService.cs`
|
- [ ] Create `Domain/Interfaces/IMistralService.cs`
|
||||||
- [x] Create `Application/Services/MistralService.cs` (uses MistralConnector)
|
- [ ] Create `Application/Services/MistralService.cs` (uses MistralConnector)
|
||||||
- [x] Implement prompt templates for different use cases
|
- [ ] Implement prompt templates for different use cases
|
||||||
- [x] Add story generation functionality
|
- [ ] Add story generation functionality
|
||||||
- [x] Add writing feedback functionality
|
- [ ] Add writing feedback functionality
|
||||||
- [x] Create Presentation/Controllers/MistralController.cs
|
- [ ] Write unit tests (with mocked MistralConnector)
|
||||||
- [ ] Write unit tests for MistralService (with mocked MistralConnector)
|
|
||||||
|
|
||||||
### Backend - Vosk Service
|
### Backend - Vosk Service
|
||||||
- [x] Create Domain/Interfaces/IVoskService.cs
|
- [ ] Create Domain/Interfaces/IVoskService.cs
|
||||||
- [x] Create Infrastructure/Services/VoskService.cs
|
- [ ] Create Infrastructure/Services/VoskService.cs
|
||||||
- [x] Set up Python process execution
|
- [ ] Set up Python process execution
|
||||||
- [ ] Download and configure vosk-model-de-0.22
|
- [ ] Download and configure vosk-model-de-0.22
|
||||||
- [x] Implement audio recognition
|
- [ ] Implement audio recognition
|
||||||
- [x] Create /api/speech/recognize endpoint
|
- [ ] Create /api/speech/recognize endpoint
|
||||||
- [x] Create Presentation/Controllers/SpeechController.cs
|
- [ ] Create Presentation/Controllers/SpeechController.cs
|
||||||
- [ ] Write unit tests for VoskService
|
- [ ] Write unit tests
|
||||||
|
|
||||||
### Backend - Coqui TTS Service
|
### Backend - Coqui TTS Service
|
||||||
- [x] Create Domain/Interfaces/ITtsService.cs
|
- [ ] Create Domain/Interfaces/ITtsService.cs
|
||||||
- [x] Create Infrastructure/Services/TtsService.cs
|
- [ ] Create Infrastructure/Services/TtsService.cs
|
||||||
- [x] Set up Python process execution
|
- [ ] Set up Python process execution
|
||||||
- [ ] Download and configure Coqui German model (requires ~1.5GB disk space)
|
- [ ] Download and configure Coqui German model
|
||||||
- [x] Implement audio generation
|
- [ ] Implement audio generation
|
||||||
- [x] Create audio file storage mechanism
|
- [ ] Create audio file storage mechanism
|
||||||
- [x] Create /api/tts/generate endpoint
|
- [ ] Create /api/tts/generate endpoint
|
||||||
- [x] Create Presentation/Controllers/TtsController.cs
|
- [ ] Create Presentation/Controllers/TtsController.cs
|
||||||
- [ ] Write unit tests for TtsService
|
- [ ] Write unit tests
|
||||||
|
|
||||||
### Backend - Higher-Level Services
|
### Backend - Higher-Level Services
|
||||||
- [ ] Create Application/Services/StoryGenerationService.cs
|
- [ ] Create Application/Services/StoryGenerationService.cs
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
# Feature: Lesson & Content Management
|
# Feature: Lesson & Content Management
|
||||||
|
|
||||||
> **Status**: ✅ Complete
|
> **Status**: 🚀 In Progress
|
||||||
> **Priority**: High
|
> **Priority**: High
|
||||||
> **Complexity**: High
|
> **Complexity**: High
|
||||||
> **Estimate**: 10-16 hours
|
> **Estimate**: 10-16 hours
|
||||||
> **Assignee**: -
|
> **Assignee**: -
|
||||||
> **Created**: May 31, 2025
|
> **Created**: May 31, 2025
|
||||||
> **Completed**: June 13, 2025
|
> **Target Completion**: -
|
||||||
> **PR**: -
|
> **PR**: -
|
||||||
> **Related Features**: Infrastructure Setup, User Authentication, Vocabulary System, Quiz System, Story Integration
|
> **Related Features**: Infrastructure Setup, User Authentication, Vocabulary System, Quiz System, Story Integration
|
||||||
|
|
||||||
|
|
@ -14,13 +14,7 @@
|
||||||
|
|
||||||
**Phase 2: Backend Services - COMPLETED ✅**
|
**Phase 2: Backend Services - COMPLETED ✅**
|
||||||
|
|
||||||
**Phase 3: API Controllers - COMPLETED ✅**
|
**All Phase 1 & Phase 2 tasks are complete. Ready for Phase 3: API Controllers.**
|
||||||
|
|
||||||
**Phase 4: Business Logic - COMPLETED ✅**
|
|
||||||
|
|
||||||
**Phase 5: Integration with Other Features - COMPLETED ✅**
|
|
||||||
|
|
||||||
**All tasks are complete. Feature is fully implemented and tested.**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -160,23 +154,23 @@ Each lesson contains:
|
||||||
- [x] Create mapping profiles (manual extension methods)
|
- [x] Create mapping profiles (manual extension methods)
|
||||||
|
|
||||||
### Phase 3: API Controllers (2-3 hours)
|
### Phase 3: API Controllers (2-3 hours)
|
||||||
- [x] Create LevelsController
|
- [ ] Create LevelsController
|
||||||
- [x] Create LessonsController
|
- [ ] Create LessonsController
|
||||||
- [x] Add authorization (Admin for write operations)
|
- [ ] Add authorization (Admin for write operations)
|
||||||
- [x] Add validation for lesson data
|
- [ ] Add validation for lesson data
|
||||||
- [x] Implement proper error handling
|
- [ ] Implement proper error handling
|
||||||
|
|
||||||
### Phase 4: Business Logic (2-3 hours)
|
### Phase 4: Business Logic (2-3 hours)
|
||||||
- [x] Implement LessonUnlockService
|
- [ ] Implement lesson unlocking logic
|
||||||
- [x] Implement LevelCompletionCalculator
|
- [ ] Calculate level completion percentage
|
||||||
- [x] Add validation for lesson order (in LessonValidators)
|
- [ ] Add next lesson recommendation
|
||||||
- [x] Add authorization checks (in controllers)
|
- [ ] Implement lesson order validation
|
||||||
|
|
||||||
### Phase 5: Integration with Other Features (1-2 hours)
|
### Phase 5: Integration with Other Features (1-2 hours)
|
||||||
- [x] Integrate with Vocabulary system (entities linked)
|
- [ ] Integrate with Vocabulary system
|
||||||
- [x] Integrate with Story system (entities linked)
|
- [ ] Integrate with Story system
|
||||||
- [x] Integrate with Quiz system (entities linked)
|
- [ ] Integrate with Quiz system
|
||||||
- [x] Update user progress on quiz completion (QuizQuestionService now calls ProgressService)
|
- [ ] Update user progress on quiz completion
|
||||||
|
|
||||||
### Milestones
|
### Milestones
|
||||||
| Milestone | Date | Status |
|
| Milestone | Date | Status |
|
||||||
|
|
@ -206,13 +200,15 @@ Each lesson contains:
|
||||||
- [x] Create Application/Services/LevelService.cs
|
- [x] Create Application/Services/LevelService.cs
|
||||||
- [x] Create Application/Services/LessonService.cs
|
- [x] Create Application/Services/LessonService.cs
|
||||||
- [x] Create Application/Services/ProgressService.cs
|
- [x] Create Application/Services/ProgressService.cs
|
||||||
- [x] Create Application/Services/LessonUnlockService.cs
|
|
||||||
- [x] Create Application/Services/LevelCompletionCalculator.cs
|
|
||||||
- [x] Write unit tests for services (30 tests)
|
- [x] Write unit tests for services (30 tests)
|
||||||
- [x] Create Presentation/Controllers/LevelsController.cs
|
- [ ] Create Application/Services/LevelService.cs
|
||||||
- [x] Create Presentation/Controllers/LessonsController.cs
|
- [ ] Create Application/Services/LessonService.cs
|
||||||
- [x] Register services in Program.cs
|
- [ ] Create Application/Services/ProgressService.cs
|
||||||
- [x] Write integration tests for controllers (34 tests)
|
- [ ] Create Presentation/Controllers/LevelsController.cs
|
||||||
|
- [ ] Create Presentation/Controllers/LessonsController.cs
|
||||||
|
- [ ] Register services in Program.cs
|
||||||
|
- [ ] Write unit tests for services
|
||||||
|
- [ ] Write integration tests for controllers
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
- [x] Create migration for Levels table
|
- [x] Create migration for Levels table
|
||||||
|
|
@ -222,16 +218,16 @@ Each lesson contains:
|
||||||
- [x] Add indexes for performance
|
- [x] Add indexes for performance
|
||||||
|
|
||||||
### Business Logic
|
### Business Logic
|
||||||
- [x] Implement LessonUnlockService
|
- [ ] Implement LessonUnlockService
|
||||||
- [x] Implement LevelCompletionCalculator
|
- [ ] Implement LevelCompletionCalculator
|
||||||
- [x] Add validation for lesson order (in LessonValidators.cs)
|
- [ ] Add validation for lesson order
|
||||||
- [x] Add authorization checks (in controllers with [Authorize] attributes)
|
- [ ] Add authorization checks
|
||||||
|
|
||||||
### Integration
|
### Integration
|
||||||
- [x] Integrate with Vocabulary feature (Lesson entity has navigation to Vocabulary)
|
- [ ] Integrate with Vocabulary feature
|
||||||
- [x] Integrate with Story feature (Lesson entity has navigation to Story)
|
- [ ] Integrate with Story feature
|
||||||
- [x] Integrate with Quiz feature (Lesson entity has navigation to Quiz)
|
- [ ] Integrate with Quiz feature
|
||||||
- [x] Update progress when quiz is passed (QuizQuestionService.SubmitQuizAnswersForQuizAsync now calls ProgressService.MarkLessonAsCompletedAsync)
|
- [ ] Update progress when quiz is passed
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue