feat: add learning domain, grading rules, permissions and Phase10aLearning migration
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e1cb189c54
commit
5f54d10223
14 files changed
+4426
No files matched your search
@@ -39,6 +39,8 @@ public static class AuditActions
|
||||
public const string AiProviderCreated = "ai.provider_created";
|
||||
public const string AiProviderUpdated = "ai.provider_updated";
|
||||
public const string AiProviderDeleted = "ai.provider_deleted";
|
||||
public const string LearningPointsCredited = "learning.points_credited";
|
||||
public const string LearningPointsReversed = "learning.points_reversed";
|
||||
|
||||
public static string DisplayName(string action) => action switch
|
||||
{
|
||||
@@ -50,6 +52,8 @@ public static class AuditActions
|
||||
AiProviderCreated => "KI-Anbieter angelegt",
|
||||
AiProviderUpdated => "KI-Anbieter geändert",
|
||||
AiProviderDeleted => "KI-Anbieter gelöscht",
|
||||
LearningPointsCredited => "Lernpunkte gutgeschrieben",
|
||||
LearningPointsReversed => "Lernpunkte zurückgebucht",
|
||||
UserCreated => "Benutzer angelegt",
|
||||
UserUpdated => "Benutzer geändert",
|
||||
UserDeleted => "Benutzer gelöscht",
|
||||
|
||||
@@ -19,6 +19,8 @@ public enum Permission
|
||||
FamilyChat,
|
||||
DirectMessages,
|
||||
AiUse,
|
||||
LearningManage,
|
||||
LearningSolve,
|
||||
UserManagement,
|
||||
CalendarManagement,
|
||||
SettingsManagement,
|
||||
@@ -68,6 +70,8 @@ public static class PermissionMatrix
|
||||
[Permission.FamilyChat] = [F, F, F, N], // Gast: nur wenn freigegeben
|
||||
[Permission.DirectMessages] = [F, F, R, N], // Kind: nur an Familienmitglieder
|
||||
[Permission.AiUse] = [F, F, N, N], // KI-Funktionen nutzen: nur Eltern (ADR-0020)
|
||||
[Permission.LearningManage] = [F, F, N, N], // Übungsblätter anlegen/zuweisen/prüfen (ADR-0021)
|
||||
[Permission.LearningSolve] = [F, F, R, N], // Kind: nur eigene Blätter lösen (ADR-0021)
|
||||
[Permission.UserManagement] = [F, N, N, N],
|
||||
[Permission.CalendarManagement] = [F, N, N, N],
|
||||
[Permission.SettingsManagement] = [F, N, N, N],
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
using FamilyPlanner.Domain.Common;
|
||||
|
||||
namespace FamilyPlanner.Domain.Learning;
|
||||
|
||||
/// <summary>Status eines Übungsblatts. Nach dem Zuweisen wird er aus den Versuchen abgeleitet (<see cref="LearningRules.DeriveStatus"/>).</summary>
|
||||
public enum LearningSetStatus
|
||||
{
|
||||
Draft = 0,
|
||||
Assigned = 1,
|
||||
InProgress = 2,
|
||||
Submitted = 3,
|
||||
Reviewed = 4,
|
||||
}
|
||||
|
||||
/// <summary><see cref="Practice"/>: Sofort-Rückmeldung und Hinweise. <see cref="Exam"/>: Prüfungsregeln (ADR-0021 Punkt 13).</summary>
|
||||
public enum LearningMode
|
||||
{
|
||||
Practice = 0,
|
||||
Exam = 1,
|
||||
}
|
||||
|
||||
/// <summary>Herkunft eines Blatts. <see cref="Ai"/> wird erst in Phase 10b gesetzt; die Felder dafür gibt es schon.</summary>
|
||||
public enum LearningSource
|
||||
{
|
||||
Manual = 0,
|
||||
Ai = 1,
|
||||
}
|
||||
|
||||
/// <summary><see cref="Paper"/> (Foto/Papierlösung) ist nur als Wert vorgesehen und wird erst in Phase 10c freigeschaltet.</summary>
|
||||
public enum LearningAnswerType
|
||||
{
|
||||
Number = 0,
|
||||
Text = 1,
|
||||
SingleChoice = 2,
|
||||
Paper = 3,
|
||||
}
|
||||
|
||||
/// <summary><see cref="Open"/>: noch nicht bewertet, wartet auf die Elternprüfung.</summary>
|
||||
public enum LearningVerdict
|
||||
{
|
||||
Open = 0,
|
||||
Correct = 1,
|
||||
Partial = 2,
|
||||
Wrong = 3,
|
||||
}
|
||||
|
||||
public enum LearningGrader
|
||||
{
|
||||
Auto = 0,
|
||||
Ai = 1,
|
||||
Parent = 2,
|
||||
}
|
||||
|
||||
public enum LearningAttemptStatus
|
||||
{
|
||||
InProgress = 0,
|
||||
Submitted = 1,
|
||||
Reviewed = 2,
|
||||
}
|
||||
|
||||
/// <summary>Die Fächer des ersten Schnitts. Fach ist ein Text aus dieser Liste – weitere Fächer brauchen keine Modelländerung.</summary>
|
||||
public static class LearningSubjects
|
||||
{
|
||||
public const string Math = "Mathe";
|
||||
public const string German = "Deutsch";
|
||||
|
||||
public static IReadOnlyList<string> All { get; } = [Math, German];
|
||||
|
||||
public static bool IsValid(string? subject) => subject is not null && All.Contains(subject, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Ein Übungsblatt (ADR-0021). Eigene Entität, kein Teil der Familienaufgaben (ADR-0014).</summary>
|
||||
public sealed class LearningSet : AuditableEntity, ISoftDeletable, IHasRowVersion
|
||||
{
|
||||
public const int TitleMaxLength = 120;
|
||||
public const int SubjectMaxLength = 40;
|
||||
public const int MinGradeLevel = 1;
|
||||
public const int MaxGradeLevel = 6;
|
||||
public const int MaxItems = 60;
|
||||
public const int AttemptsLimit = 20;
|
||||
public const int MaxTimeLimitMinutes = 240;
|
||||
public const int MaxTotalPoints = 100;
|
||||
public const int GradeThresholdsMaxLength = 40;
|
||||
public const int AiPromptMaxLength = 2000;
|
||||
public const int AiProviderMaxLength = 100;
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Text aus <see cref="LearningSubjects.All"/>.</summary>
|
||||
public string Subject { get; set; } = LearningSubjects.Math;
|
||||
|
||||
public int GradeLevel { get; set; } = 1;
|
||||
|
||||
/// <summary>Das Kind, dem das Blatt zugewiesen ist (Rolle Kind; ohne Fremdschlüssel wie bei Aufgaben).</summary>
|
||||
public Guid TargetUserId { get; set; }
|
||||
|
||||
/// <summary>Fälligkeitstag in der Familienzeitzone.</summary>
|
||||
public DateOnly? DueDate { get; set; }
|
||||
|
||||
public LearningSetStatus Status { get; set; } = LearningSetStatus.Draft;
|
||||
|
||||
public LearningMode Mode { get; set; } = LearningMode.Practice;
|
||||
|
||||
/// <summary>Anzahl Versuche; 0 = unbegrenzt.</summary>
|
||||
public int MaxAttempts { get; set; } = 1;
|
||||
|
||||
/// <summary>Zeitlimit je Versuch in Minuten; <c>null</c> = keins.</summary>
|
||||
public int? TimeLimitMinutes { get; set; }
|
||||
|
||||
/// <summary>Musterlösung und Erklärung nach der Abgabe zeigen.</summary>
|
||||
public bool ShowSolutionsAfterSubmit { get; set; }
|
||||
|
||||
/// <summary>Aufgabenreihenfolge je Versuch mischen (stabil je Versuch).</summary>
|
||||
public bool ShuffleItems { get; set; }
|
||||
|
||||
/// <summary>Punkte, die das Blatt bei voller Punktzahl im Punktebuch ergibt; 0 = keine Punkte.</summary>
|
||||
public int TotalPoints { get; set; }
|
||||
|
||||
/// <summary>Notenschlüssel für Prüfungen: Mindestprozent für die Noten 1 bis 5 (z. B. <c>92;81;67;50;30</c>); <c>null</c> = Standard.</summary>
|
||||
public string? GradeThresholds { get; set; }
|
||||
|
||||
public LearningSource Source { get; set; } = LearningSource.Manual;
|
||||
|
||||
/// <summary>Nur bei <see cref="LearningSource.Ai"/> (Phase 10b): verwendeter Prompt, Anbieter und Modell.</summary>
|
||||
public string? AiPrompt { get; set; }
|
||||
|
||||
public string? AiProvider { get; set; }
|
||||
|
||||
public string? AiModel { get; set; }
|
||||
|
||||
public DateTimeOffset? AssignedAt { get; set; }
|
||||
|
||||
/// <summary>Wird bei jeder Änderung an den Aufgaben erhöht und sperrt so gleichzeitige Bearbeitung (zusammen mit <see cref="RowVersion"/>).</summary>
|
||||
public int ItemsRevision { get; set; }
|
||||
|
||||
public List<LearningItem> Items { get; set; } = [];
|
||||
|
||||
public List<LearningAttempt> Attempts { get; set; } = [];
|
||||
|
||||
public bool IsDeleted { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public Guid? DeletedBy { get; set; }
|
||||
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Eine Aufgabe eines Blatts.</summary>
|
||||
public sealed class LearningItem : AuditableEntity, ISoftDeletable, IHasRowVersion
|
||||
{
|
||||
public const int QuestionMaxLength = 1000;
|
||||
public const int CorrectAnswerMaxLength = 200;
|
||||
public const int HintMaxLength = 500;
|
||||
public const int ExplanationMaxLength = 1000;
|
||||
public const int OptionMaxLength = 100;
|
||||
public const int MinOptions = 2;
|
||||
public const int MaxOptions = 6;
|
||||
public const int MinDifficulty = 1;
|
||||
public const int MaxDifficulty = 3;
|
||||
public const int MinPoints = 1;
|
||||
public const int MaxPoints = 20;
|
||||
|
||||
public Guid SetId { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public string Question { get; set; } = string.Empty;
|
||||
|
||||
public LearningAnswerType AnswerType { get; set; } = LearningAnswerType.Number;
|
||||
|
||||
/// <summary>
|
||||
/// Musterlösung: Zahl (<see cref="LearningAnswerType.Number"/>), Text bzw. mehrere mit <c>|</c> getrennte Varianten
|
||||
/// (<see cref="LearningAnswerType.Text"/>) oder der Index der richtigen Option (<see cref="LearningAnswerType.SingleChoice"/>).
|
||||
/// </summary>
|
||||
public string? CorrectAnswer { get; set; }
|
||||
|
||||
/// <summary>Absolute Toleranz für Zahlenantworten; <c>null</c> = exakt.</summary>
|
||||
public decimal? NumberTolerance { get; set; }
|
||||
|
||||
/// <summary>Auswahloptionen (nur <see cref="LearningAnswerType.SingleChoice"/>).</summary>
|
||||
public List<string> Options { get; set; } = [];
|
||||
|
||||
/// <summary>Hinweis für das Kind – nur im Übungsmodus sichtbar.</summary>
|
||||
public string? Hint { get; set; }
|
||||
|
||||
/// <summary>Erklärung zur Musterlösung – nur nach der Abgabe und nur wenn „Lösungen zeigen“ aktiv ist.</summary>
|
||||
public string? Explanation { get; set; }
|
||||
|
||||
/// <summary>1 = leicht, 2 = mittel, 3 = schwer.</summary>
|
||||
public int Difficulty { get; set; } = 1;
|
||||
|
||||
public int Points { get; set; } = 1;
|
||||
|
||||
public bool IsDeleted { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public Guid? DeletedBy { get; set; }
|
||||
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Ein Bearbeitungsversuch eines Kindes. Wiederholungen sind weitere Versuche (<see cref="Number"/> zählt hoch).</summary>
|
||||
public sealed class LearningAttempt : AuditableEntity, IHasRowVersion
|
||||
{
|
||||
public const int NoteMaxLength = 500;
|
||||
public const int ItemOrderMaxLength = 2400;
|
||||
|
||||
public Guid SetId { get; set; }
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>1, 2, 3 … je Blatt und Kind.</summary>
|
||||
public int Number { get; set; }
|
||||
|
||||
public LearningAttemptStatus Status { get; set; } = LearningAttemptStatus.InProgress;
|
||||
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
|
||||
/// <summary>Ablauf des Zeitlimits (vom Server beim Start festgelegt); <c>null</c> = kein Limit.</summary>
|
||||
public DateTimeOffset? DeadlineAt { get; set; }
|
||||
|
||||
/// <summary>Letzte Speicherung einer Antwort. Das Ändern schützt per <see cref="RowVersion"/> vor Speichern während der Abgabe.</summary>
|
||||
public DateTimeOffset LastActivityAt { get; set; }
|
||||
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
|
||||
/// <summary>Abgabe erfolgte wegen Zeitablauf, nicht durch das Kind.</summary>
|
||||
public bool AutoSubmitted { get; set; }
|
||||
|
||||
/// <summary>Kommagetrennte Aufgaben-Ids in der Reihenfolge dieses Versuchs (stabil, ggf. gemischt).</summary>
|
||||
public string ItemOrder { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset? ReviewedAt { get; set; }
|
||||
|
||||
public Guid? ReviewedBy { get; set; }
|
||||
|
||||
/// <summary>Erreichte Punkte, gültig ab <see cref="LearningAttemptStatus.Reviewed"/>.</summary>
|
||||
public int? EarnedPoints { get; set; }
|
||||
|
||||
public int? TotalItemPoints { get; set; }
|
||||
|
||||
/// <summary>Schulnote 1–6, nur für Prüfungen und gültig ab <see cref="LearningAttemptStatus.Reviewed"/>.</summary>
|
||||
public int? Grade { get; set; }
|
||||
|
||||
public string? ReviewNote { get; set; }
|
||||
|
||||
public List<LearningAnswer> Answers { get; set; } = [];
|
||||
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Antwort des Kindes auf eine Aufgabe und deren Bewertung.</summary>
|
||||
public sealed class LearningAnswer : AuditableEntity
|
||||
{
|
||||
public const int ValueMaxLength = 500;
|
||||
public const int ReasonMaxLength = 500;
|
||||
|
||||
public Guid AttemptId { get; set; }
|
||||
|
||||
public Guid ItemId { get; set; }
|
||||
|
||||
/// <summary>Antwort des Kindes (Zahl/Text als Eingabe, bei Auswahl der Index der Option); <c>null</c> = nicht beantwortet.</summary>
|
||||
public string? Value { get; set; }
|
||||
|
||||
public DateTimeOffset? AnsweredAt { get; set; }
|
||||
|
||||
public LearningVerdict Verdict { get; set; } = LearningVerdict.Open;
|
||||
|
||||
public LearningGrader Grader { get; set; } = LearningGrader.Auto;
|
||||
|
||||
/// <summary>Erreichte Punkte dieser Aufgabe (0 bis Aufgabenpunkte).</summary>
|
||||
public int AwardedPoints { get; set; }
|
||||
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public DateTimeOffset? GradedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace FamilyPlanner.Domain.Learning;
|
||||
|
||||
/// <summary>Ergebnis der automatischen Bewertung einer Antwort.</summary>
|
||||
public sealed record AutoGradeResult(LearningVerdict Verdict, int AwardedPoints, string? Reason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Liest Zahlen, wie Kinder sie tippen: Komma oder Punkt als Dezimalzeichen, Punkt/Komma/Leerzeichen als Tausendertrenner.
|
||||
/// Läuft unabhängig von der Kultur des Geräts (immer <see cref="CultureInfo.InvariantCulture"/>), damit Api und WASM-Client gleich rechnen.
|
||||
/// Brüche („1/2“) werden bewusst nicht erkannt.
|
||||
/// </summary>
|
||||
public static class LearningNumbers
|
||||
{
|
||||
private const int MaxLength = 28;
|
||||
|
||||
/// <summary>
|
||||
/// Mögliche Werte der Eingabe. Meist genau einer; bei „1.234“ bzw. „1,234“ (genau drei Nachkommastellen, 1–3 Ziffern davor)
|
||||
/// sind Dezimalzahl und Tausendertrennung möglich – dann kommen beide Werte zurück (Dezimalwert zuerst).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<decimal> Parse(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(text.Length);
|
||||
foreach (var c in text.Trim())
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case ' ' or ' ' or ' ' or ' ':
|
||||
continue; // Tausender-Leerzeichen
|
||||
case '−' or '–':
|
||||
builder.Append('-');
|
||||
break;
|
||||
default:
|
||||
builder.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var s = builder.ToString();
|
||||
if (s.Length == 0 || s.Length > MaxLength)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var negative = false;
|
||||
if (s[0] is '-' or '+')
|
||||
{
|
||||
negative = s[0] == '-';
|
||||
s = s[1..];
|
||||
}
|
||||
|
||||
if (s.Length == 0 || !s.All(c => char.IsAsciiDigit(c) || c is '.' or ',') || !s.Any(char.IsAsciiDigit))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var candidates = Candidates(s);
|
||||
return candidates
|
||||
.Select(value => negative ? -value : value)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Erster (bevorzugter) Wert oder <c>null</c>, wenn keine Zahl erkannt wurde.</summary>
|
||||
public static decimal? ParseFirst(string? text) => Parse(text) is { Count: > 0 } list ? list[0] : null;
|
||||
|
||||
private static List<decimal> Candidates(string s)
|
||||
{
|
||||
var dots = s.Count(c => c == '.');
|
||||
var commas = s.Count(c => c == ',');
|
||||
|
||||
if (dots == 0 && commas == 0)
|
||||
{
|
||||
return Single(TryDecimal(s));
|
||||
}
|
||||
|
||||
if (dots > 0 && commas > 0)
|
||||
{
|
||||
// Das letzte Trennzeichen ist das Komma der Dezimalstelle, das andere trennt Tausender.
|
||||
var last = s.LastIndexOfAny(['.', ',']);
|
||||
var decimalSeparator = s[last];
|
||||
var thousandsSeparator = decimalSeparator == '.' ? ',' : '.';
|
||||
if (s.Count(c => c == decimalSeparator) != 1)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var integerPart = s[..last];
|
||||
var fraction = s[(last + 1)..];
|
||||
return IsThousandsGrouped(integerPart, thousandsSeparator)
|
||||
? Single(TryDecimal(integerPart.Replace(thousandsSeparator.ToString(), string.Empty) + "." + fraction))
|
||||
: [];
|
||||
}
|
||||
|
||||
var separator = dots > 0 ? '.' : ',';
|
||||
var count = Math.Max(dots, commas);
|
||||
if (count > 1)
|
||||
{
|
||||
// Mehrfach vorkommend: nur als Tausendertrenner sinnvoll („1.234.567“).
|
||||
return IsThousandsGrouped(s, separator)
|
||||
? Single(TryDecimal(s.Replace(separator.ToString(), string.Empty)))
|
||||
: [];
|
||||
}
|
||||
|
||||
var index = s.IndexOf(separator);
|
||||
var before = s[..index];
|
||||
var after = s[(index + 1)..];
|
||||
var asDecimal = TryDecimal((before.Length == 0 ? "0" : before) + "." + after);
|
||||
var ambiguous = after.Length == 3 && before.Length is >= 1 and <= 3 && before[0] != '0' && after.All(char.IsAsciiDigit);
|
||||
var result = Single(asDecimal);
|
||||
if (ambiguous && TryDecimal(before + after) is { } grouped)
|
||||
{
|
||||
result.Add(grouped);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>1–3 Ziffern, danach Gruppen aus genau drei Ziffern, getrennt durch <paramref name="separator"/>.</summary>
|
||||
private static bool IsThousandsGrouped(string text, char separator)
|
||||
{
|
||||
var groups = text.Split(separator);
|
||||
return groups.Length >= 2
|
||||
&& groups[0].Length is >= 1 and <= 3
|
||||
&& groups.All(g => g.All(char.IsAsciiDigit))
|
||||
&& groups.Skip(1).All(g => g.Length == 3);
|
||||
}
|
||||
|
||||
private static List<decimal> Single(decimal? value) => value is { } v ? [v] : [];
|
||||
|
||||
private static decimal? TryDecimal(string text) =>
|
||||
decimal.TryParse(text, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>Automatische Bewertung ohne KI (ADR-0021 Punkt 3): Zahlen und Auswahl endgültig, Freitext nur bei exakter Übereinstimmung.</summary>
|
||||
public static class LearningGrading
|
||||
{
|
||||
/// <summary>Text vergleichbar machen: Unicode-Normalform, Leerraum zusammenfassen, Groß-/Kleinschreibung und Randzeichen ignorieren.</summary>
|
||||
public static string NormalizeText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = text.Normalize(NormalizationForm.FormC);
|
||||
var builder = new StringBuilder(normalized.Length);
|
||||
var pendingSpace = false;
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
if (char.IsWhiteSpace(c))
|
||||
{
|
||||
pendingSpace = builder.Length > 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pendingSpace)
|
||||
{
|
||||
builder.Append(' ');
|
||||
pendingSpace = false;
|
||||
}
|
||||
|
||||
builder.Append(c);
|
||||
}
|
||||
|
||||
return builder.ToString().Trim().TrimEnd('.', '!', '?', ';').Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Bewertet die Antwort einer Aufgabe. Eine leere Antwort ist immer falsch.</summary>
|
||||
public static AutoGradeResult Grade(LearningItem item, string? answer)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(answer))
|
||||
{
|
||||
return new AutoGradeResult(LearningVerdict.Wrong, 0, "Keine Antwort.");
|
||||
}
|
||||
|
||||
return item.AnswerType switch
|
||||
{
|
||||
LearningAnswerType.Number => GradeNumber(item, answer),
|
||||
LearningAnswerType.SingleChoice => GradeChoice(item, answer),
|
||||
LearningAnswerType.Text => GradeText(item, answer),
|
||||
_ => new AutoGradeResult(LearningVerdict.Open, 0), // Paper: Prüfung durch Eltern (Phase 10c)
|
||||
};
|
||||
}
|
||||
|
||||
private static AutoGradeResult GradeNumber(LearningItem item, string answer)
|
||||
{
|
||||
var given = LearningNumbers.Parse(answer);
|
||||
if (given.Count == 0)
|
||||
{
|
||||
return new AutoGradeResult(LearningVerdict.Wrong, 0, "Keine Zahl erkannt.");
|
||||
}
|
||||
|
||||
var expected = LearningNumbers.Parse(item.CorrectAnswer);
|
||||
var tolerance = Math.Abs(item.NumberTolerance ?? 0m);
|
||||
var correct = given.Any(g => expected.Any(e => Math.Abs(g - e) <= tolerance));
|
||||
return correct ? Correct(item) : new AutoGradeResult(LearningVerdict.Wrong, 0);
|
||||
}
|
||||
|
||||
private static AutoGradeResult GradeChoice(LearningItem item, string answer)
|
||||
{
|
||||
var correct = int.TryParse(answer.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var chosen)
|
||||
&& int.TryParse(item.CorrectAnswer?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var expected)
|
||||
&& chosen == expected
|
||||
&& chosen < item.Options.Count;
|
||||
return correct ? Correct(item) : new AutoGradeResult(LearningVerdict.Wrong, 0);
|
||||
}
|
||||
|
||||
private static AutoGradeResult GradeText(LearningItem item, string answer)
|
||||
{
|
||||
var given = NormalizeText(answer);
|
||||
var accepted = (item.CorrectAnswer ?? string.Empty).Split('|').Select(NormalizeText).Where(a => a.Length > 0);
|
||||
return accepted.Contains(given, StringComparer.Ordinal)
|
||||
? Correct(item)
|
||||
: new AutoGradeResult(LearningVerdict.Open, 0); // Abweichungen entscheiden die Eltern (Schreibweise, Synonyme)
|
||||
}
|
||||
|
||||
private static AutoGradeResult Correct(LearningItem item) => new(LearningVerdict.Correct, item.Points);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
using System.Security.Cryptography;
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
|
||||
namespace FamilyPlanner.Domain.Learning;
|
||||
|
||||
/// <summary>Der handelnde Benutzer mit seinen Lern-Rechten (Rollenmatrix, ADR-0008); gleich gebildet in Api und Client.</summary>
|
||||
public sealed record LearningActor(Guid UserId, bool CanManage, AccessLevel Solve)
|
||||
{
|
||||
public static LearningActor From(Guid userId, IEnumerable<string> roles, PermissionOptions? options = null)
|
||||
{
|
||||
var list = roles.ToList();
|
||||
return new LearningActor(
|
||||
userId,
|
||||
PermissionMatrix.For(Permission.LearningManage, list, options) == AccessLevel.Full,
|
||||
PermissionMatrix.For(Permission.LearningSolve, list, options));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Voraussetzung für einen neuen Versuch.</summary>
|
||||
public enum AttemptStart
|
||||
{
|
||||
Ok = 0,
|
||||
|
||||
/// <summary>Blatt ist (noch) nicht zugewiesen.</summary>
|
||||
NotAssigned = 1,
|
||||
|
||||
/// <summary>Es läuft bereits ein Versuch – dieser wird fortgesetzt.</summary>
|
||||
Running = 2,
|
||||
|
||||
/// <summary>Alle erlaubten Versuche sind verbraucht.</summary>
|
||||
NoAttemptsLeft = 3,
|
||||
}
|
||||
|
||||
/// <summary>Was das Kind zu einer Aufgabe sehen darf – Grundlage aller Kinder-DTOs (nie Lösungen vorab).</summary>
|
||||
public sealed record LearningVisibility(bool ShowHint, bool ShowVerdict, bool ShowSolution);
|
||||
|
||||
/// <summary>Fachliche Regeln der Lernaufgaben, rein und ohne Abhängigkeit von EF/ASP.NET (ADR-0021).</summary>
|
||||
public static class LearningRules
|
||||
{
|
||||
/// <summary>Feste Toleranz für Netzverzögerungen: Speicherungen bis so lange nach dem Ablauf werden noch akzeptiert.</summary>
|
||||
public static readonly TimeSpan DeadlineTolerance = TimeSpan.FromSeconds(10);
|
||||
|
||||
// ---------- Rechte ----------
|
||||
|
||||
/// <summary>Lösen darf nur die Zielperson, und nur zugewiesene Blätter (Entwürfe sind für Kinder unsichtbar).</summary>
|
||||
public static bool CanSolve(LearningActor actor, Guid targetUserId, LearningSetStatus status) =>
|
||||
actor.Solve != AccessLevel.None && targetUserId == actor.UserId && status != LearningSetStatus.Draft;
|
||||
|
||||
// ---------- Zeitlimit ----------
|
||||
|
||||
/// <summary>Ablaufzeitpunkt eines Versuchs oder <c>null</c> ohne Zeitlimit.</summary>
|
||||
public static DateTimeOffset? DeadlineFor(DateTimeOffset startedAt, int? timeLimitMinutes) =>
|
||||
timeLimitMinutes is > 0 ? startedAt.AddMinutes(timeLimitMinutes.Value) : null;
|
||||
|
||||
/// <summary>Antworten dürfen noch gespeichert werden (Frist plus feste Toleranz).</summary>
|
||||
public static bool AcceptsSaves(DateTimeOffset? deadline, DateTimeOffset now) =>
|
||||
deadline is null || now <= deadline.Value + DeadlineTolerance;
|
||||
|
||||
/// <summary>Die Frist samt Toleranz ist vorbei: der Versuch muss automatisch abgegeben werden.</summary>
|
||||
public static bool MustAutoSubmit(DateTimeOffset? deadline, DateTimeOffset now) => !AcceptsSaves(deadline, now);
|
||||
|
||||
/// <summary>Zeitstempel der Abgabe: nie nach der Frist.</summary>
|
||||
public static DateTimeOffset SubmittedAtFor(DateTimeOffset? deadline, DateTimeOffset now) =>
|
||||
deadline is { } d && now > d ? d : now;
|
||||
|
||||
/// <summary>Verbleibende Zeit (nie negativ) oder <c>null</c> ohne Zeitlimit.</summary>
|
||||
public static TimeSpan? Remaining(DateTimeOffset? deadline, DateTimeOffset now) =>
|
||||
deadline is { } d ? (d - now < TimeSpan.Zero ? TimeSpan.Zero : d - now) : null;
|
||||
|
||||
// ---------- Versuche und Status ----------
|
||||
|
||||
/// <summary>Status des Blatts aus den Versuchen (ab Zuweisung). Entwürfe bleiben Entwürfe.</summary>
|
||||
public static LearningSetStatus DeriveStatus(LearningSetStatus current, IReadOnlyCollection<LearningAttemptStatus> attempts)
|
||||
{
|
||||
if (current == LearningSetStatus.Draft)
|
||||
{
|
||||
return LearningSetStatus.Draft;
|
||||
}
|
||||
|
||||
if (attempts.Contains(LearningAttemptStatus.InProgress))
|
||||
{
|
||||
return LearningSetStatus.InProgress;
|
||||
}
|
||||
|
||||
if (attempts.Contains(LearningAttemptStatus.Submitted))
|
||||
{
|
||||
return LearningSetStatus.Submitted;
|
||||
}
|
||||
|
||||
return attempts.Contains(LearningAttemptStatus.Reviewed) ? LearningSetStatus.Reviewed : LearningSetStatus.Assigned;
|
||||
}
|
||||
|
||||
/// <summary>Darf ein neuer Versuch gestartet werden? <paramref name="maxAttempts"/> = 0 bedeutet unbegrenzt.</summary>
|
||||
public static AttemptStart CanStartAttempt(LearningSetStatus status, int maxAttempts, IReadOnlyCollection<LearningAttemptStatus> attempts)
|
||||
{
|
||||
if (status == LearningSetStatus.Draft)
|
||||
{
|
||||
return AttemptStart.NotAssigned;
|
||||
}
|
||||
|
||||
if (attempts.Contains(LearningAttemptStatus.InProgress))
|
||||
{
|
||||
return AttemptStart.Running;
|
||||
}
|
||||
|
||||
return maxAttempts > 0 && attempts.Count >= maxAttempts ? AttemptStart.NoAttemptsLeft : AttemptStart.Ok;
|
||||
}
|
||||
|
||||
public static int AttemptsLeft(int maxAttempts, int used) => maxAttempts <= 0 ? int.MaxValue : Math.Max(0, maxAttempts - used);
|
||||
|
||||
/// <summary>Aufgaben dürfen nur geändert werden, solange es noch keinen Versuch gibt (sonst ändern sich Bewertungen nachträglich).</summary>
|
||||
public static bool CanEditItems(int attemptCount) => attemptCount == 0;
|
||||
|
||||
/// <summary>Zuweisen: mindestens eine Aufgabe, jede mit gültiger Musterlösung. Liefert eine Fehlermeldung oder <c>null</c>.</summary>
|
||||
public static string? CheckAssignable(LearningSetStatus status, IReadOnlyCollection<LearningItem> items)
|
||||
{
|
||||
if (status != LearningSetStatus.Draft)
|
||||
{
|
||||
return "Das Blatt ist bereits zugewiesen.";
|
||||
}
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return "Das Blatt braucht mindestens eine Aufgabe.";
|
||||
}
|
||||
|
||||
var broken = items.FirstOrDefault(i => LearningItemRules.CheckSolution(i) is not null);
|
||||
return broken is null ? null : $"Aufgabe {broken.SortOrder + 1}: {LearningItemRules.CheckSolution(broken)}";
|
||||
}
|
||||
|
||||
// ---------- Sichtbarkeit ----------
|
||||
|
||||
/// <summary>
|
||||
/// Übung: Hinweise und Sofort-Rückmeldung (nur richtig/falsch, nie die Lösung) während der Bearbeitung.
|
||||
/// Prüfung: während der Bearbeitung nichts. Nach der Abgabe: Bewertung immer, Lösung nur wenn eingestellt, Hinweise nur in der Übung.
|
||||
/// </summary>
|
||||
public static LearningVisibility VisibilityFor(LearningMode mode, LearningAttemptStatus status, bool showSolutionsAfterSubmit)
|
||||
{
|
||||
var practice = mode == LearningMode.Practice;
|
||||
return status == LearningAttemptStatus.InProgress
|
||||
? new LearningVisibility(ShowHint: practice, ShowVerdict: practice, ShowSolution: false)
|
||||
: new LearningVisibility(ShowHint: practice, ShowVerdict: true, ShowSolution: showSolutionsAfterSubmit);
|
||||
}
|
||||
|
||||
// ---------- Reihenfolge ----------
|
||||
|
||||
/// <summary>
|
||||
/// Aufgaben-Reihenfolge eines Versuchs. Gemischt wird deterministisch (SHA-256 aus Versuchs- und Aufgaben-Id), damit die
|
||||
/// Reihenfolge je Versuch stabil ist, aber zwischen Versuchen wechselt. <c>string.GetHashCode</c> wäre je Prozess zufällig.
|
||||
/// </summary>
|
||||
public static List<Guid> OrderItems(IReadOnlyList<Guid> itemIdsInSortOrder, bool shuffle, Guid attemptId)
|
||||
{
|
||||
if (!shuffle)
|
||||
{
|
||||
return [.. itemIdsInSortOrder];
|
||||
}
|
||||
|
||||
return itemIdsInSortOrder
|
||||
.Select(id => (Id: id, Key: ShuffleKey(attemptId, id)))
|
||||
.OrderBy(x => x.Key)
|
||||
.ThenBy(x => x.Id)
|
||||
.Select(x => x.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static ulong ShuffleKey(Guid attemptId, Guid itemId)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[32];
|
||||
attemptId.TryWriteBytes(bytes[..16]);
|
||||
itemId.TryWriteBytes(bytes[16..]);
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return BitConverter.ToUInt64(hash[..8]);
|
||||
}
|
||||
|
||||
public static string SerializeOrder(IEnumerable<Guid> ids) => string.Join(',', ids);
|
||||
|
||||
public static List<Guid> ParseOrder(string? text) =>
|
||||
string.IsNullOrWhiteSpace(text)
|
||||
? []
|
||||
: text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(part => Guid.TryParse(part, out var id) ? id : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.ToList();
|
||||
|
||||
// ---------- Bewertung durch Eltern ----------
|
||||
|
||||
/// <summary>
|
||||
/// Prüft eine Elternbewertung und liefert die zu speichernden Punkte. Richtig = volle Punkte, falsch = 0, teilweise = 1 bis
|
||||
/// Aufgabenpunkte − 1 (ohne Angabe die Hälfte, abgerundet, mindestens 1). „Offen“ ist als Ergebnis nicht erlaubt.
|
||||
/// </summary>
|
||||
public static (int Points, string? Error) NormalizeParentVerdict(LearningVerdict verdict, int? awardedPoints, int itemPoints)
|
||||
{
|
||||
switch (verdict)
|
||||
{
|
||||
case LearningVerdict.Correct:
|
||||
return (itemPoints, null);
|
||||
case LearningVerdict.Wrong:
|
||||
return (0, null);
|
||||
case LearningVerdict.Partial:
|
||||
if (itemPoints < 2)
|
||||
{
|
||||
return (0, "Teilpunkte gibt es erst ab 2 Aufgabenpunkten.");
|
||||
}
|
||||
|
||||
var points = awardedPoints ?? Math.Max(1, itemPoints / 2);
|
||||
return points is >= 1 && points <= itemPoints - 1
|
||||
? (points, null)
|
||||
: (0, $"Teilpunkte müssen zwischen 1 und {itemPoints - 1} liegen.");
|
||||
default:
|
||||
return (0, "Bitte „richtig“, „teilweise“ oder „falsch“ wählen.");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Punkte ----------
|
||||
|
||||
/// <summary>Erreichte Punkte (offene Antworten zählen 0), Gesamtpunkte und Anzahl offener Antworten.</summary>
|
||||
public static LearningScore Score(IEnumerable<ScoredAnswer> answers)
|
||||
{
|
||||
int earned = 0, total = 0, open = 0, correct = 0, count = 0;
|
||||
foreach (var answer in answers)
|
||||
{
|
||||
count++;
|
||||
total += answer.ItemPoints;
|
||||
switch (answer.Verdict)
|
||||
{
|
||||
case LearningVerdict.Open:
|
||||
open++;
|
||||
break;
|
||||
case LearningVerdict.Correct:
|
||||
correct++;
|
||||
earned += answer.ItemPoints;
|
||||
break;
|
||||
default:
|
||||
earned += Math.Clamp(answer.AwardedPoints, 0, answer.ItemPoints);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new LearningScore(earned, total, open, correct, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Punkte, die das Blatt insgesamt im Punktebuch ergeben soll: die <b>beste</b> bestätigte Wertung (Anteil erreichter Punkte)
|
||||
/// mal <paramref name="setTotalPoints"/>, kaufmännisch auf ganze Punkte gerundet (halbe aufwärts). Ohne bestätigten Versuch 0.
|
||||
/// </summary>
|
||||
public static int TargetCredit(int setTotalPoints, IEnumerable<(int Earned, int Total)> confirmedAttempts)
|
||||
{
|
||||
if (setTotalPoints <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
(int Earned, int Total)? best = null;
|
||||
foreach (var attempt in confirmedAttempts.Where(a => a.Total > 0))
|
||||
{
|
||||
// Brüche ohne Gleitkomma vergleichen: a/b > c/d <=> a*d > c*b
|
||||
if (best is null || (long)attempt.Earned * best.Value.Total > (long)best.Value.Earned * attempt.Total)
|
||||
{
|
||||
best = attempt;
|
||||
}
|
||||
}
|
||||
|
||||
return best is { } b ? (int)((2L * b.Earned * setTotalPoints + b.Total) / (2L * b.Total)) : 0;
|
||||
}
|
||||
|
||||
/// <summary>Zu buchende Differenz zum bereits gebuchten Stand (negativ = Rückbuchung).</summary>
|
||||
public static int CreditDelta(int target, int alreadyBooked) => target - alreadyBooked;
|
||||
}
|
||||
|
||||
/// <summary>Eine bewertete Antwort für die Punkteberechnung.</summary>
|
||||
public sealed record ScoredAnswer(int ItemPoints, LearningVerdict Verdict, int AwardedPoints);
|
||||
|
||||
public sealed record LearningScore(int Earned, int Total, int OpenCount, int CorrectCount, int AnswerCount)
|
||||
{
|
||||
public double Percent => Total <= 0 ? 0 : Earned * 100.0 / Total;
|
||||
}
|
||||
|
||||
/// <summary>Notenschlüssel: Mindestprozent für die Noten 1 bis 5; darunter gibt es die 6. Standard: Schulnoten 92/81/67/50/30.</summary>
|
||||
public static class LearningGrades
|
||||
{
|
||||
public static IReadOnlyList<int> DefaultThresholds { get; } = [92, 81, 67, 50, 30];
|
||||
|
||||
/// <summary>Liest <c>92;81;67;50;30</c>; leer = Standard. Fünf absteigende Prozentwerte zwischen 1 und 100.</summary>
|
||||
public static bool TryParse(string? text, out IReadOnlyList<int> thresholds, out string? error)
|
||||
{
|
||||
thresholds = DefaultThresholds;
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var parts = text.Split([';', ','], StringSplitOptions.TrimEntries);
|
||||
var values = new List<int>();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (!int.TryParse(part, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
error = "Der Notenschlüssel braucht fünf Prozentwerte, getrennt durch Semikolon (z. B. 92;81;67;50;30).";
|
||||
return false;
|
||||
}
|
||||
|
||||
values.Add(value);
|
||||
}
|
||||
|
||||
if (values.Count != 5 || values.Any(v => v is < 1 or > 100))
|
||||
{
|
||||
error = "Der Notenschlüssel braucht fünf Prozentwerte zwischen 1 und 100.";
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 1; i < values.Count; i++)
|
||||
{
|
||||
if (values[i] >= values[i - 1])
|
||||
{
|
||||
error = "Die Prozentwerte des Notenschlüssels müssen von Note 1 bis 5 abnehmen.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
thresholds = values;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Normalisierte Schreibweise zum Speichern; <c>null</c> für den Standard.</summary>
|
||||
public static string? Format(IReadOnlyList<int> thresholds) =>
|
||||
thresholds.SequenceEqual(DefaultThresholds) ? null : string.Join(';', thresholds);
|
||||
|
||||
/// <summary>Note 1–6 aus erreichten und möglichen Punkten (exakter Ganzzahlvergleich, keine Rundungsfehler).</summary>
|
||||
public static int GradeFor(int earned, int total, IReadOnlyList<int>? thresholds = null)
|
||||
{
|
||||
thresholds ??= DefaultThresholds;
|
||||
if (total <= 0)
|
||||
{
|
||||
return 6;
|
||||
}
|
||||
|
||||
for (var i = 0; i < thresholds.Count; i++)
|
||||
{
|
||||
if (earned * 100L >= (long)thresholds[i] * total)
|
||||
{
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 6;
|
||||
}
|
||||
|
||||
public static string Label(int grade) => grade switch
|
||||
{
|
||||
1 => "sehr gut",
|
||||
2 => "gut",
|
||||
3 => "befriedigend",
|
||||
4 => "ausreichend",
|
||||
5 => "mangelhaft",
|
||||
6 => "ungenügend",
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Prüfung der Felder einer Aufgabe, die der Validator ohne Entität nicht kennt.</summary>
|
||||
public static class LearningItemRules
|
||||
{
|
||||
/// <summary>Typ-spezifische Prüfung der Musterlösung. Liefert eine Fehlermeldung oder <c>null</c>.</summary>
|
||||
public static string? CheckSolution(LearningItem item)
|
||||
{
|
||||
switch (item.AnswerType)
|
||||
{
|
||||
case LearningAnswerType.Number:
|
||||
return LearningNumbers.Parse(item.CorrectAnswer).Count == 0 ? "Die Musterlösung muss eine Zahl sein." : null;
|
||||
case LearningAnswerType.Text:
|
||||
return string.IsNullOrWhiteSpace(item.CorrectAnswer) ? "Bitte eine Musterlösung angeben." : null;
|
||||
case LearningAnswerType.SingleChoice:
|
||||
if (item.Options.Count is < LearningItem.MinOptions or > LearningItem.MaxOptions)
|
||||
{
|
||||
return $"Eine Auswahlaufgabe braucht {LearningItem.MinOptions} bis {LearningItem.MaxOptions} Optionen.";
|
||||
}
|
||||
|
||||
return int.TryParse(item.CorrectAnswer, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var index)
|
||||
&& index < item.Options.Count
|
||||
? null
|
||||
: "Bitte die richtige Option wählen.";
|
||||
default:
|
||||
return "Papieraufgaben sind noch nicht verfügbar.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Antworttypen, die in dieser Ausbaustufe angelegt werden dürfen (<see cref="LearningAnswerType.Paper"/> folgt in Phase 10c).</summary>
|
||||
public static bool IsSupported(LearningAnswerType type) => type is LearningAnswerType.Number or LearningAnswerType.Text or LearningAnswerType.SingleChoice;
|
||||
|
||||
/// <summary>Vorschlag für die Punkte nach Schwierigkeit (leicht 1, mittel 2, schwer 3).</summary>
|
||||
public static int DefaultPoints(int difficulty) => Math.Clamp(difficulty, LearningItem.MinDifficulty, LearningItem.MaxDifficulty);
|
||||
}
|
||||
@@ -228,5 +228,8 @@ public sealed class PointsLedgerEntry : AuditableEntity
|
||||
|
||||
public Guid? TaskId { get; set; }
|
||||
|
||||
/// <summary>Buchung zu einem Lernblatt (ADR-0021): Gutschrift, Verbesserung oder Rückbuchung.</summary>
|
||||
public Guid? LearningSetId { get; set; }
|
||||
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using FamilyPlanner.Domain.Ai;
|
||||
using FamilyPlanner.Domain.Audit;
|
||||
using FamilyPlanner.Domain.Calendar;
|
||||
using FamilyPlanner.Domain.Chat;
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
using FamilyPlanner.Domain.Meals;
|
||||
using FamilyPlanner.Domain.Push;
|
||||
using FamilyPlanner.Domain.Settings;
|
||||
@@ -70,6 +71,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
|
||||
public DbSet<AiUsage> AiUsage => Set<AiUsage>();
|
||||
|
||||
public DbSet<LearningSet> LearningSets => Set<LearningSet>();
|
||||
|
||||
public DbSet<LearningItem> LearningItems => Set<LearningItem>();
|
||||
|
||||
public DbSet<LearningAttempt> LearningAttempts => Set<LearningAttempt>();
|
||||
|
||||
public DbSet<LearningAnswer> LearningAnswers => Set<LearningAnswer>();
|
||||
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace FamilyPlanner.Infrastructure.Persistence.Configurations;
|
||||
|
||||
internal sealed class LearningSetConfiguration : IEntityTypeConfiguration<LearningSet>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LearningSet> builder)
|
||||
{
|
||||
builder.ToTable("LearningSets");
|
||||
builder.Property(s => s.Title).HasMaxLength(LearningSet.TitleMaxLength).IsRequired();
|
||||
builder.Property(s => s.Subject).HasMaxLength(LearningSet.SubjectMaxLength).IsRequired();
|
||||
builder.Property(s => s.Status).HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(s => s.Mode).HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(s => s.Source).HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(s => s.GradeThresholds).HasMaxLength(LearningSet.GradeThresholdsMaxLength);
|
||||
builder.Property(s => s.AiPrompt).HasMaxLength(LearningSet.AiPromptMaxLength);
|
||||
builder.Property(s => s.AiProvider).HasMaxLength(LearningSet.AiProviderMaxLength);
|
||||
builder.Property(s => s.AiModel).HasMaxLength(LearningSet.AiProviderMaxLength);
|
||||
builder.Property(s => s.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasMany(s => s.Items).WithOne().HasForeignKey(i => i.SetId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasMany(s => s.Attempts).WithOne().HasForeignKey(a => a.SetId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Kind-Ansicht „meine Blätter“ und Eltern-Übersicht.
|
||||
builder.HasIndex(s => new { s.TargetUserId, s.Status });
|
||||
builder.HasIndex(s => s.Status);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LearningItemConfiguration : IEntityTypeConfiguration<LearningItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LearningItem> builder)
|
||||
{
|
||||
builder.ToTable("LearningItems");
|
||||
builder.Property(i => i.Question).HasMaxLength(LearningItem.QuestionMaxLength).IsRequired();
|
||||
builder.Property(i => i.AnswerType).HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(i => i.CorrectAnswer).HasMaxLength(LearningItem.CorrectAnswerMaxLength);
|
||||
builder.Property(i => i.NumberTolerance).HasPrecision(18, 6);
|
||||
builder.PrimitiveCollection(i => i.Options).IsRequired();
|
||||
builder.Property(i => i.Hint).HasMaxLength(LearningItem.HintMaxLength);
|
||||
builder.Property(i => i.Explanation).HasMaxLength(LearningItem.ExplanationMaxLength);
|
||||
builder.Property(i => i.RowVersion).IsRowVersion();
|
||||
builder.HasIndex(i => new { i.SetId, i.SortOrder });
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LearningAttemptConfiguration : IEntityTypeConfiguration<LearningAttempt>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LearningAttempt> builder)
|
||||
{
|
||||
builder.ToTable("LearningAttempts");
|
||||
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(a => a.ItemOrder).HasMaxLength(LearningAttempt.ItemOrderMaxLength).IsRequired();
|
||||
builder.Property(a => a.ReviewNote).HasMaxLength(LearningAttempt.NoteMaxLength);
|
||||
builder.Property(a => a.RowVersion).IsRowVersion();
|
||||
|
||||
// Der Benutzer ist eine bloße Id (kein Fremdschlüssel, wie bei Aufgaben) – das vermeidet mehrfache Kaskadenpfade.
|
||||
builder.HasMany(a => a.Answers).WithOne().HasForeignKey(x => x.AttemptId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Versuchsnummern sind je Blatt und Kind eindeutig; höchstens ein laufender Versuch – schützt vor Doppelklick/zwei Tabs
|
||||
// (umgeht weder MaxAttempts noch setzt es die Prüfungsuhr zurück).
|
||||
builder.HasIndex(a => new { a.SetId, a.UserId, a.Number }).IsUnique();
|
||||
builder.HasIndex(a => new { a.SetId, a.UserId }).IsUnique().HasFilter("[Status] = 'InProgress'");
|
||||
builder.HasIndex(a => new { a.Status, a.DeadlineAt });
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LearningAnswerConfiguration : IEntityTypeConfiguration<LearningAnswer>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LearningAnswer> builder)
|
||||
{
|
||||
builder.ToTable("LearningAnswers");
|
||||
builder.Property(a => a.Value).HasMaxLength(LearningAnswer.ValueMaxLength);
|
||||
builder.Property(a => a.Verdict).HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(a => a.Grader).HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(a => a.Reason).HasMaxLength(LearningAnswer.ReasonMaxLength);
|
||||
|
||||
// ItemId ist bewusst keine Beziehung (Aufgaben werden nach dem ersten Versuch nicht mehr verändert; kein zweiter Kaskadenpfad).
|
||||
builder.HasIndex(a => new { a.AttemptId, a.ItemId }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ internal sealed class PointsLedgerEntryConfiguration : IEntityTypeConfiguration<
|
||||
builder.Property(p => p.Reason).HasMaxLength(PointsLedgerEntry.ReasonMaxLength).IsRequired();
|
||||
builder.HasIndex(p => p.UserId);
|
||||
builder.HasIndex(p => p.TaskId);
|
||||
builder.HasIndex(p => p.LearningSetId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+2291
File diff suppressed because it is too large.
Load diff
+241
@@ -0,0 +1,241 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Phase10aLearning : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "LearningSetId",
|
||||
table: "PointsLedger",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LearningSets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Title = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
|
||||
Subject = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
|
||||
GradeLevel = table.Column<int>(type: "int", nullable: false),
|
||||
TargetUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
DueDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
Mode = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
MaxAttempts = table.Column<int>(type: "int", nullable: false),
|
||||
TimeLimitMinutes = table.Column<int>(type: "int", nullable: true),
|
||||
ShowSolutionsAfterSubmit = table.Column<bool>(type: "bit", nullable: false),
|
||||
ShuffleItems = table.Column<bool>(type: "bit", nullable: false),
|
||||
TotalPoints = table.Column<int>(type: "int", nullable: false),
|
||||
GradeThresholds = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: true),
|
||||
Source = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
AiPrompt = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
AiProvider = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
AiModel = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
AssignedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
ItemsRevision = table.Column<int>(type: "int", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LearningSets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LearningAttempts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
SetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Number = table.Column<int>(type: "int", nullable: false),
|
||||
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
StartedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
DeadlineAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
LastActivityAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
SubmittedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
AutoSubmitted = table.Column<bool>(type: "bit", nullable: false),
|
||||
ItemOrder = table.Column<string>(type: "nvarchar(2400)", maxLength: 2400, nullable: false),
|
||||
ReviewedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
ReviewedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
EarnedPoints = table.Column<int>(type: "int", nullable: true),
|
||||
TotalItemPoints = table.Column<int>(type: "int", nullable: true),
|
||||
Grade = table.Column<int>(type: "int", nullable: true),
|
||||
ReviewNote = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LearningAttempts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_LearningAttempts_LearningSets_SetId",
|
||||
column: x => x.SetId,
|
||||
principalTable: "LearningSets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LearningItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
SetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false),
|
||||
Question = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
||||
AnswerType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
CorrectAnswer = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
NumberTolerance = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: true),
|
||||
Options = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Hint = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
Explanation = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||
Difficulty = table.Column<int>(type: "int", nullable: false),
|
||||
Points = table.Column<int>(type: "int", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LearningItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_LearningItems_LearningSets_SetId",
|
||||
column: x => x.SetId,
|
||||
principalTable: "LearningSets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LearningAnswers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
AttemptId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ItemId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
AnsweredAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
Verdict = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
Grader = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
AwardedPoints = table.Column<int>(type: "int", nullable: false),
|
||||
Reason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
GradedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LearningAnswers", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_LearningAnswers_LearningAttempts_AttemptId",
|
||||
column: x => x.AttemptId,
|
||||
principalTable: "LearningAttempts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PointsLedger_LearningSetId",
|
||||
table: "PointsLedger",
|
||||
column: "LearningSetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LearningAnswers_AttemptId_ItemId",
|
||||
table: "LearningAnswers",
|
||||
columns: new[] { "AttemptId", "ItemId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LearningAttempts_SetId_UserId",
|
||||
table: "LearningAttempts",
|
||||
columns: new[] { "SetId", "UserId" },
|
||||
unique: true,
|
||||
filter: "[Status] = 'InProgress'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LearningAttempts_SetId_UserId_Number",
|
||||
table: "LearningAttempts",
|
||||
columns: new[] { "SetId", "UserId", "Number" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LearningAttempts_Status_DeadlineAt",
|
||||
table: "LearningAttempts",
|
||||
columns: new[] { "Status", "DeadlineAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LearningItems_IsDeleted",
|
||||
table: "LearningItems",
|
||||
column: "IsDeleted");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LearningItems_SetId_SortOrder",
|
||||
table: "LearningItems",
|
||||
columns: new[] { "SetId", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LearningSets_IsDeleted",
|
||||
table: "LearningSets",
|
||||
column: "IsDeleted");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LearningSets_Status",
|
||||
table: "LearningSets",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LearningSets_TargetUserId_Status",
|
||||
table: "LearningSets",
|
||||
columns: new[] { "TargetUserId", "Status" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "LearningAnswers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LearningItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LearningAttempts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LearningSets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_PointsLedger_LearningSetId",
|
||||
table: "PointsLedger");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LearningSetId",
|
||||
table: "PointsLedger");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -682,6 +682,355 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("ChatReactions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAnswer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset?>("AnsweredAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid>("AttemptId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("AwardedPoints")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset?>("GradedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Grader")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<Guid>("ItemId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("Verdict")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AttemptId", "ItemId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("LearningAnswers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAttempt", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<bool>("AutoSubmitted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeadlineAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("EarnedPoints")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("Grade")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ItemOrder")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2400)
|
||||
.HasColumnType("nvarchar(2400)");
|
||||
|
||||
b.Property<DateTimeOffset>("LastActivityAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ReviewNote")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReviewedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("ReviewedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("rowversion");
|
||||
|
||||
b.Property<Guid>("SetId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("StartedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("TotalItemPoints")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SetId", "UserId")
|
||||
.IsUnique()
|
||||
.HasFilter("[Status] = 'InProgress'");
|
||||
|
||||
b.HasIndex("Status", "DeadlineAt");
|
||||
|
||||
b.HasIndex("SetId", "UserId", "Number")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("LearningAttempts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("AnswerType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("CorrectAnswer")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("Difficulty")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Explanation")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("Hint")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<decimal?>("NumberTolerance")
|
||||
.HasPrecision(18, 6)
|
||||
.HasColumnType("decimal(18,6)");
|
||||
|
||||
b.PrimitiveCollection<string>("Options")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Points")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Question")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("rowversion");
|
||||
|
||||
b.Property<Guid>("SetId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted");
|
||||
|
||||
b.HasIndex("SetId", "SortOrder");
|
||||
|
||||
b.ToTable("LearningItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningSet", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("AiModel")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("AiPrompt")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("AiProvider")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("AssignedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateOnly?>("DueDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int>("GradeLevel")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("GradeThresholds")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("nvarchar(40)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("ItemsRevision")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("MaxAttempts")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("rowversion");
|
||||
|
||||
b.Property<bool>("ShowSolutionsAfterSubmit")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("ShuffleItems")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("nvarchar(40)");
|
||||
|
||||
b.Property<Guid>("TargetUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("TimeLimitMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)");
|
||||
|
||||
b.Property<int>("TotalPoints")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("TargetUserId", "Status");
|
||||
|
||||
b.ToTable("LearningSets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Meals.Dish", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1166,6 +1515,9 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("LearningSetId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("Points")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -1188,6 +1540,8 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LearningSetId");
|
||||
|
||||
b.HasIndex("TaskId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
@@ -1791,6 +2145,33 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAnswer", b =>
|
||||
{
|
||||
b.HasOne("FamilyPlanner.Domain.Learning.LearningAttempt", null)
|
||||
.WithMany("Answers")
|
||||
.HasForeignKey("AttemptId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAttempt", b =>
|
||||
{
|
||||
b.HasOne("FamilyPlanner.Domain.Learning.LearningSet", null)
|
||||
.WithMany("Attempts")
|
||||
.HasForeignKey("SetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningItem", b =>
|
||||
{
|
||||
b.HasOne("FamilyPlanner.Domain.Learning.LearningSet", null)
|
||||
.WithMany("Items")
|
||||
.HasForeignKey("SetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Shopping.ShoppingItem", b =>
|
||||
{
|
||||
b.HasOne("FamilyPlanner.Domain.Shopping.ShoppingList", null)
|
||||
@@ -1883,6 +2264,18 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Permissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAttempt", b =>
|
||||
{
|
||||
b.Navigation("Answers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningSet", b =>
|
||||
{
|
||||
b.Navigation("Attempts");
|
||||
|
||||
b.Navigation("Items");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Tasks.TodoTask", b =>
|
||||
{
|
||||
b.Navigation("Assignments");
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
using FluentValidation;
|
||||
|
||||
namespace FamilyPlanner.Shared.Learning;
|
||||
|
||||
// =====================================================================================================================
|
||||
// Eltern (Permission LearningManage): Blätter, Aufgaben mit Lösungen, Prüfung. Diese DTOs gehen NIE an Kinder.
|
||||
// =====================================================================================================================
|
||||
|
||||
/// <summary>Aufgabe mit Musterlösung, Hinweis und Erklärung – nur für Eltern.</summary>
|
||||
public sealed record LearningItemDto(
|
||||
Guid Id,
|
||||
int SortOrder,
|
||||
string Question,
|
||||
LearningAnswerType AnswerType,
|
||||
string? CorrectAnswer,
|
||||
decimal? NumberTolerance,
|
||||
IReadOnlyList<string> Options,
|
||||
string? Hint,
|
||||
string? Explanation,
|
||||
int Difficulty,
|
||||
int Points,
|
||||
byte[] Version);
|
||||
|
||||
/// <summary>Blatt ohne Aufgaben (Listen, Übersicht) inkl. Kennzahlen; Prompt/Anbieter/Modell nur für Eltern.</summary>
|
||||
public sealed record LearningSetDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string Subject,
|
||||
int GradeLevel,
|
||||
Guid TargetUserId,
|
||||
DateOnly? DueDate,
|
||||
LearningSetStatus Status,
|
||||
LearningMode Mode,
|
||||
int MaxAttempts,
|
||||
int? TimeLimitMinutes,
|
||||
bool ShowSolutionsAfterSubmit,
|
||||
bool ShuffleItems,
|
||||
int TotalPoints,
|
||||
string? GradeThresholds,
|
||||
LearningSource Source,
|
||||
string? AiPrompt,
|
||||
string? AiProvider,
|
||||
string? AiModel,
|
||||
DateTimeOffset? AssignedAt,
|
||||
int ItemCount,
|
||||
int AttemptCount,
|
||||
int PendingReviewCount,
|
||||
int PointsCredited,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
byte[] Version);
|
||||
|
||||
public sealed record LearningAttemptSummaryDto(
|
||||
Guid Id,
|
||||
int Number,
|
||||
LearningAttemptStatus Status,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset? SubmittedAt,
|
||||
bool AutoSubmitted,
|
||||
int? EarnedPoints,
|
||||
int? TotalItemPoints,
|
||||
int? Grade);
|
||||
|
||||
public sealed record LearningSetDetailDto(
|
||||
LearningSetDto Set,
|
||||
IReadOnlyList<LearningItemDto> Items,
|
||||
IReadOnlyList<LearningAttemptSummaryDto> Attempts);
|
||||
|
||||
/// <summary>Gemeinsame, bearbeitbare Felder einer Aufgabe.</summary>
|
||||
public interface ILearningItemFields
|
||||
{
|
||||
string Question { get; }
|
||||
|
||||
LearningAnswerType AnswerType { get; }
|
||||
|
||||
string? CorrectAnswer { get; }
|
||||
|
||||
decimal? NumberTolerance { get; }
|
||||
|
||||
IReadOnlyList<string>? Options { get; }
|
||||
|
||||
string? Hint { get; }
|
||||
|
||||
string? Explanation { get; }
|
||||
|
||||
int Difficulty { get; }
|
||||
|
||||
int Points { get; }
|
||||
}
|
||||
|
||||
/// <summary>Neue Aufgabe (auch als Teil von <see cref="CreateLearningSetRequest.Items"/>).</summary>
|
||||
public sealed record LearningItemInput(
|
||||
string Question,
|
||||
LearningAnswerType AnswerType,
|
||||
string? CorrectAnswer,
|
||||
decimal? NumberTolerance = null,
|
||||
IReadOnlyList<string>? Options = null,
|
||||
string? Hint = null,
|
||||
string? Explanation = null,
|
||||
int Difficulty = 1,
|
||||
int Points = 1) : ILearningItemFields;
|
||||
|
||||
/// <summary>Aufgabe ändern. Passt <see cref="Version"/> nicht, antwortet die Api mit 409 und dem aktuellen Stand.</summary>
|
||||
public sealed record UpdateLearningItemRequest(
|
||||
string Question,
|
||||
LearningAnswerType AnswerType,
|
||||
string? CorrectAnswer,
|
||||
decimal? NumberTolerance = null,
|
||||
IReadOnlyList<string>? Options = null,
|
||||
string? Hint = null,
|
||||
string? Explanation = null,
|
||||
int Difficulty = 1,
|
||||
int Points = 1,
|
||||
byte[]? Version = null) : ILearningItemFields
|
||||
{
|
||||
public static UpdateLearningItemRequest From(LearningItemDto item) => new(
|
||||
item.Question, item.AnswerType, item.CorrectAnswer, item.NumberTolerance, item.Options, item.Hint, item.Explanation,
|
||||
item.Difficulty, item.Points, item.Version);
|
||||
}
|
||||
|
||||
public sealed record ReorderLearningItemsRequest(IReadOnlyList<Guid> ItemIds);
|
||||
|
||||
/// <summary>Gemeinsame Einstellungen eines Blatts.</summary>
|
||||
public interface ILearningSetFields
|
||||
{
|
||||
string Title { get; }
|
||||
|
||||
string Subject { get; }
|
||||
|
||||
int GradeLevel { get; }
|
||||
|
||||
Guid TargetUserId { get; }
|
||||
|
||||
DateOnly? DueDate { get; }
|
||||
|
||||
LearningMode Mode { get; }
|
||||
|
||||
int MaxAttempts { get; }
|
||||
|
||||
int? TimeLimitMinutes { get; }
|
||||
|
||||
bool ShowSolutionsAfterSubmit { get; }
|
||||
|
||||
bool ShuffleItems { get; }
|
||||
|
||||
int TotalPoints { get; }
|
||||
|
||||
string? GradeThresholds { get; }
|
||||
}
|
||||
|
||||
/// <summary>Neues Blatt (als Entwurf), optional gleich mit Aufgaben. Herkunft „KI“ setzt nur der Server (Phase 10b).</summary>
|
||||
public sealed record CreateLearningSetRequest(
|
||||
string Title,
|
||||
string Subject,
|
||||
int GradeLevel,
|
||||
Guid TargetUserId,
|
||||
DateOnly? DueDate = null,
|
||||
LearningMode Mode = LearningMode.Practice,
|
||||
int MaxAttempts = 0,
|
||||
int? TimeLimitMinutes = null,
|
||||
bool ShowSolutionsAfterSubmit = true,
|
||||
bool ShuffleItems = false,
|
||||
int TotalPoints = 0,
|
||||
string? GradeThresholds = null,
|
||||
IReadOnlyList<LearningItemInput>? Items = null) : ILearningSetFields;
|
||||
|
||||
/// <summary>Blatt ändern (alle Einstellungen). Passt <see cref="Version"/> nicht, antwortet die Api mit 409 und dem aktuellen Stand.</summary>
|
||||
public sealed record UpdateLearningSetRequest(
|
||||
string Title,
|
||||
string Subject,
|
||||
int GradeLevel,
|
||||
Guid TargetUserId,
|
||||
DateOnly? DueDate = null,
|
||||
LearningMode Mode = LearningMode.Practice,
|
||||
int MaxAttempts = 0,
|
||||
int? TimeLimitMinutes = null,
|
||||
bool ShowSolutionsAfterSubmit = true,
|
||||
bool ShuffleItems = false,
|
||||
int TotalPoints = 0,
|
||||
string? GradeThresholds = null,
|
||||
byte[]? Version = null) : ILearningSetFields
|
||||
{
|
||||
public static UpdateLearningSetRequest From(LearningSetDto set) => new(
|
||||
set.Title, set.Subject, set.GradeLevel, set.TargetUserId, set.DueDate, set.Mode, set.MaxAttempts, set.TimeLimitMinutes,
|
||||
set.ShowSolutionsAfterSubmit, set.ShuffleItems, set.TotalPoints, set.GradeThresholds, set.Version);
|
||||
}
|
||||
|
||||
/// <summary>Eintrag der Prüf-Warteschlange: abgegebener, noch nicht bestätigter Versuch.</summary>
|
||||
public sealed record LearningReviewQueueItemDto(
|
||||
Guid AttemptId,
|
||||
Guid SetId,
|
||||
string SetTitle,
|
||||
string Subject,
|
||||
LearningMode Mode,
|
||||
Guid ChildId,
|
||||
string ChildName,
|
||||
int Number,
|
||||
DateTimeOffset SubmittedAt,
|
||||
bool AutoSubmitted,
|
||||
int OpenCount,
|
||||
int EarnedPoints,
|
||||
int TotalItemPoints);
|
||||
|
||||
/// <summary>Aufgabe eines Versuchs aus Elternsicht: Frage, Musterlösung, Kinderantwort und Bewertung.</summary>
|
||||
public sealed record LearningReviewAnswerDto(
|
||||
Guid ItemId,
|
||||
int Position,
|
||||
string Question,
|
||||
LearningAnswerType AnswerType,
|
||||
IReadOnlyList<string> Options,
|
||||
string? CorrectAnswer,
|
||||
string? Explanation,
|
||||
int ItemPoints,
|
||||
string? Answer,
|
||||
LearningVerdict Verdict,
|
||||
LearningGrader Grader,
|
||||
int AwardedPoints,
|
||||
string? Reason);
|
||||
|
||||
public sealed record LearningAttemptReviewDto(
|
||||
Guid AttemptId,
|
||||
Guid SetId,
|
||||
string SetTitle,
|
||||
string Subject,
|
||||
LearningMode Mode,
|
||||
int SetTotalPoints,
|
||||
Guid ChildId,
|
||||
string ChildName,
|
||||
int Number,
|
||||
LearningAttemptStatus Status,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset? SubmittedAt,
|
||||
bool AutoSubmitted,
|
||||
IReadOnlyList<LearningReviewAnswerDto> Answers,
|
||||
int EarnedPoints,
|
||||
int TotalItemPoints,
|
||||
int OpenCount,
|
||||
int? Grade,
|
||||
int PointsCredited,
|
||||
string? ReviewNote,
|
||||
byte[] Version);
|
||||
|
||||
/// <summary>Elternbewertung einer Antwort: richtig, teilweise (mit Punkten) oder falsch; optional mit Begründung.</summary>
|
||||
public sealed record ReviewAnswerRequest(LearningVerdict Verdict, int? AwardedPoints = null, string? Reason = null);
|
||||
|
||||
public sealed record ConfirmAttemptRequest(string? Note = null);
|
||||
|
||||
/// <summary>Ergebnis von Bestätigen/Erneut öffnen: der Versuch und die gebuchte Punktedifferenz (negativ = Rückbuchung).</summary>
|
||||
public sealed record LearningReviewResultDto(LearningAttemptReviewDto Attempt, int PointsDelta, int PointsCredited);
|
||||
|
||||
// =====================================================================================================================
|
||||
// Kind (Permission LearningSolve): eigene Blätter lösen. Eigene Typen OHNE Musterlösung/Prompt/Anbieter – Lösungen und
|
||||
// Hinweise stehen nur in <see cref="LearningAttemptItemDto"/>, wenn die Regeln es erlauben (LearningRules.VisibilityFor).
|
||||
// =====================================================================================================================
|
||||
|
||||
/// <summary>Ergebnis eines Versuchs aus Kindersicht (nach der Abgabe). <see cref="IsFinal"/>: von Eltern bestätigt.</summary>
|
||||
public sealed record MyLearningResultDto(
|
||||
Guid AttemptId,
|
||||
int Number,
|
||||
LearningAttemptStatus Status,
|
||||
DateTimeOffset? SubmittedAt,
|
||||
bool AutoSubmitted,
|
||||
int EarnedPoints,
|
||||
int TotalItemPoints,
|
||||
int OpenCount,
|
||||
int? Grade,
|
||||
bool IsFinal);
|
||||
|
||||
/// <summary>Blatt in „Meine Blätter“ – ohne Aufgaben.</summary>
|
||||
public sealed record MyLearningSetDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string Subject,
|
||||
int GradeLevel,
|
||||
DateOnly? DueDate,
|
||||
LearningSetStatus Status,
|
||||
LearningMode Mode,
|
||||
int? TimeLimitMinutes,
|
||||
int ItemCount,
|
||||
int TotalPoints,
|
||||
int MaxAttempts,
|
||||
int AttemptsUsed,
|
||||
bool CanStart,
|
||||
Guid? RunningAttemptId,
|
||||
int PointsCredited,
|
||||
IReadOnlyList<MyLearningResultDto> Results);
|
||||
|
||||
/// <summary>Rückmeldung zu einer Antwort bzw. nach der Abgabe; Felder nur, wenn <c>LearningRules.VisibilityFor</c> sie erlaubt.</summary>
|
||||
public sealed record LearningItemFeedbackDto(
|
||||
LearningVerdict Verdict,
|
||||
int AwardedPoints,
|
||||
string? Reason,
|
||||
string? CorrectAnswer,
|
||||
string? Explanation);
|
||||
|
||||
/// <summary>Aufgabe in der Kinderansicht. <see cref="Hint"/> nur im Übungsmodus, <see cref="Feedback"/> nur wenn erlaubt.</summary>
|
||||
public sealed record LearningAttemptItemDto(
|
||||
Guid ItemId,
|
||||
int Position,
|
||||
string Question,
|
||||
LearningAnswerType AnswerType,
|
||||
IReadOnlyList<string> Options,
|
||||
int Points,
|
||||
int Difficulty,
|
||||
string? Answer,
|
||||
string? Hint,
|
||||
LearningItemFeedbackDto? Feedback);
|
||||
|
||||
/// <summary>Ein Versuch aus Kindersicht. <see cref="ServerNow"/> dient dem Client zum Abgleich der Uhr (Countdown zur <see cref="DeadlineAt"/>).</summary>
|
||||
public sealed record LearningAttemptDto(
|
||||
Guid Id,
|
||||
Guid SetId,
|
||||
string SetTitle,
|
||||
string Subject,
|
||||
LearningMode Mode,
|
||||
int Number,
|
||||
LearningAttemptStatus Status,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset? DeadlineAt,
|
||||
DateTimeOffset ServerNow,
|
||||
bool AutoSubmitted,
|
||||
IReadOnlyList<LearningAttemptItemDto> Items,
|
||||
MyLearningResultDto? Result,
|
||||
byte[] Version);
|
||||
|
||||
/// <summary>Antwort speichern (leer = löschen). Idempotent und zustandsbasiert: es zählt der zuletzt gespeicherte Wert.</summary>
|
||||
public sealed record SaveAnswerRequest(string? Value);
|
||||
|
||||
/// <summary>Bestätigung einer gespeicherten Antwort; <see cref="Verdict"/> nur im Übungsmodus (Sofort-Rückmeldung, nie die Lösung).</summary>
|
||||
public sealed record SaveAnswerResultDto(
|
||||
Guid ItemId,
|
||||
bool Answered,
|
||||
LearningVerdict? Verdict,
|
||||
DateTimeOffset? DeadlineAt,
|
||||
DateTimeOffset ServerNow);
|
||||
|
||||
// =====================================================================================================================
|
||||
// Live und Routen
|
||||
// =====================================================================================================================
|
||||
|
||||
/// <summary>Namen der SignalR-Ereignisse: ohne Inhalte, nur an Zielperson und Eltern; der Client lädt über die geschützten Endpunkte nach.</summary>
|
||||
public static class LearningEvents
|
||||
{
|
||||
public const string Changed = "LearningChanged";
|
||||
}
|
||||
|
||||
public enum LearningChangeKind
|
||||
{
|
||||
SetChanged = 0,
|
||||
Assigned = 1,
|
||||
Submitted = 2,
|
||||
Reviewed = 3,
|
||||
Deleted = 4,
|
||||
}
|
||||
|
||||
public sealed record LearningChangedDto(Guid SetId, LearningChangeKind Kind);
|
||||
|
||||
// =====================================================================================================================
|
||||
// Validierung (Api verbindlich, Client für sofortiges Feedback)
|
||||
// =====================================================================================================================
|
||||
|
||||
public static class LearningValidation
|
||||
{
|
||||
internal static void SetFields<T>(AbstractValidator<T> validator)
|
||||
where T : ILearningSetFields
|
||||
{
|
||||
validator.RuleFor(r => r.Title)
|
||||
.Must(t => !string.IsNullOrWhiteSpace(t)).WithMessage("Bitte einen Titel eingeben.")
|
||||
.Must(t => t is null || t.Trim().Length <= LearningSet.TitleMaxLength)
|
||||
.WithMessage($"Der Titel darf höchstens {LearningSet.TitleMaxLength} Zeichen haben.");
|
||||
validator.RuleFor(r => r.Subject).Must(LearningSubjects.IsValid).WithMessage("Bitte ein Fach wählen (Mathe oder Deutsch).");
|
||||
validator.RuleFor(r => r.GradeLevel).InclusiveBetween(LearningSet.MinGradeLevel, LearningSet.MaxGradeLevel)
|
||||
.WithMessage("Die Klassenstufe muss zwischen 1 und 6 liegen.");
|
||||
validator.RuleFor(r => r.TargetUserId).NotEmpty().WithMessage("Bitte ein Kind auswählen.");
|
||||
validator.RuleFor(r => r.Mode).IsInEnum().WithMessage("Unbekannter Modus.");
|
||||
validator.RuleFor(r => r.MaxAttempts).InclusiveBetween(0, LearningSet.AttemptsLimit)
|
||||
.WithMessage($"Die Zahl der Versuche muss zwischen 0 (unbegrenzt) und {LearningSet.AttemptsLimit} liegen.");
|
||||
validator.RuleFor(r => r.TimeLimitMinutes).InclusiveBetween(1, LearningSet.MaxTimeLimitMinutes)
|
||||
.When(r => r.TimeLimitMinutes is not null)
|
||||
.WithMessage($"Das Zeitlimit muss zwischen 1 und {LearningSet.MaxTimeLimitMinutes} Minuten liegen.");
|
||||
validator.RuleFor(r => r.TotalPoints).InclusiveBetween(0, LearningSet.MaxTotalPoints)
|
||||
.WithMessage($"Die Gesamtpunkte müssen zwischen 0 und {LearningSet.MaxTotalPoints} liegen.");
|
||||
validator.RuleFor(r => r.GradeThresholds)
|
||||
.Must(text => LearningGrades.TryParse(text, out _, out _))
|
||||
.WithMessage((request, text) => LearningGrades.TryParse(text, out _, out var error) ? string.Empty : error ?? "Ungültiger Notenschlüssel.");
|
||||
}
|
||||
|
||||
internal static void ItemFields<T>(AbstractValidator<T> validator)
|
||||
where T : ILearningItemFields
|
||||
{
|
||||
validator.RuleFor(r => r.Question)
|
||||
.Must(q => !string.IsNullOrWhiteSpace(q)).WithMessage("Bitte eine Frage eingeben.")
|
||||
.Must(q => q is null || q.Trim().Length <= LearningItem.QuestionMaxLength)
|
||||
.WithMessage($"Die Frage darf höchstens {LearningItem.QuestionMaxLength} Zeichen haben.");
|
||||
validator.RuleFor(r => r.AnswerType).Must(LearningItemRules.IsSupported)
|
||||
.WithMessage("Dieser Antworttyp ist noch nicht verfügbar.");
|
||||
validator.RuleFor(r => r.CorrectAnswer).MaximumLength(LearningItem.CorrectAnswerMaxLength)
|
||||
.WithMessage($"Die Musterlösung darf höchstens {LearningItem.CorrectAnswerMaxLength} Zeichen haben.");
|
||||
validator.RuleFor(r => r.Hint).MaximumLength(LearningItem.HintMaxLength)
|
||||
.WithMessage($"Der Hinweis darf höchstens {LearningItem.HintMaxLength} Zeichen haben.");
|
||||
validator.RuleFor(r => r.Explanation).MaximumLength(LearningItem.ExplanationMaxLength)
|
||||
.WithMessage($"Die Erklärung darf höchstens {LearningItem.ExplanationMaxLength} Zeichen haben.");
|
||||
validator.RuleFor(r => r.Difficulty).InclusiveBetween(LearningItem.MinDifficulty, LearningItem.MaxDifficulty)
|
||||
.WithMessage("Die Schwierigkeit muss leicht, mittel oder schwer sein.");
|
||||
validator.RuleFor(r => r.Points).InclusiveBetween(LearningItem.MinPoints, LearningItem.MaxPoints)
|
||||
.WithMessage($"Die Punkte müssen zwischen {LearningItem.MinPoints} und {LearningItem.MaxPoints} liegen.");
|
||||
validator.RuleFor(r => r.NumberTolerance).InclusiveBetween(0m, 1_000_000m).When(r => r.NumberTolerance is not null)
|
||||
.WithMessage("Die Toleranz muss zwischen 0 und 1.000.000 liegen.");
|
||||
validator.RuleFor(r => r.Options)
|
||||
.Must(options => options is null || options.All(o => !string.IsNullOrWhiteSpace(o) && o.Trim().Length <= LearningItem.OptionMaxLength))
|
||||
.WithMessage($"Optionen brauchen einen Text mit höchstens {LearningItem.OptionMaxLength} Zeichen.");
|
||||
validator.RuleFor(r => r)
|
||||
.Must(r => LearningItemRules.CheckSolution(ToItem(r)) is null)
|
||||
.When(r => LearningItemRules.IsSupported(r.AnswerType))
|
||||
.WithName(nameof(ILearningItemFields.CorrectAnswer))
|
||||
.WithMessage(r => LearningItemRules.CheckSolution(ToItem(r)) ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Entität aus den Eingabefeldern (bereinigt) – Grundlage der Prüfung und des Speicherns.</summary>
|
||||
public static LearningItem ToItem(ILearningItemFields fields) => new()
|
||||
{
|
||||
Question = fields.Question?.Trim() ?? string.Empty,
|
||||
AnswerType = fields.AnswerType,
|
||||
CorrectAnswer = string.IsNullOrWhiteSpace(fields.CorrectAnswer) ? null : fields.CorrectAnswer.Trim(),
|
||||
NumberTolerance = fields.AnswerType == LearningAnswerType.Number ? fields.NumberTolerance : null,
|
||||
Options = fields.AnswerType == LearningAnswerType.SingleChoice
|
||||
? (fields.Options ?? []).Select(o => o?.Trim() ?? string.Empty).ToList()
|
||||
: [],
|
||||
Hint = string.IsNullOrWhiteSpace(fields.Hint) ? null : fields.Hint.Trim(),
|
||||
Explanation = string.IsNullOrWhiteSpace(fields.Explanation) ? null : fields.Explanation.Trim(),
|
||||
Difficulty = fields.Difficulty,
|
||||
Points = fields.Points,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class CreateLearningSetRequestValidator : AbstractValidator<CreateLearningSetRequest>
|
||||
{
|
||||
public CreateLearningSetRequestValidator()
|
||||
{
|
||||
LearningValidation.SetFields(this);
|
||||
RuleFor(r => r.Items)
|
||||
.Must(items => items is null || items.Count <= LearningSet.MaxItems)
|
||||
.WithMessage($"Ein Blatt kann höchstens {LearningSet.MaxItems} Aufgaben haben.");
|
||||
RuleForEach(r => r.Items).SetValidator(new LearningItemInputValidator());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateLearningSetRequestValidator : AbstractValidator<UpdateLearningSetRequest>
|
||||
{
|
||||
public UpdateLearningSetRequestValidator() => LearningValidation.SetFields(this);
|
||||
}
|
||||
|
||||
public sealed class LearningItemInputValidator : AbstractValidator<LearningItemInput>
|
||||
{
|
||||
public LearningItemInputValidator() => LearningValidation.ItemFields(this);
|
||||
}
|
||||
|
||||
public sealed class UpdateLearningItemRequestValidator : AbstractValidator<UpdateLearningItemRequest>
|
||||
{
|
||||
public UpdateLearningItemRequestValidator() => LearningValidation.ItemFields(this);
|
||||
}
|
||||
|
||||
public sealed class ReorderLearningItemsRequestValidator : AbstractValidator<ReorderLearningItemsRequest>
|
||||
{
|
||||
public ReorderLearningItemsRequestValidator() =>
|
||||
RuleFor(r => r.ItemIds)
|
||||
.NotNull().WithMessage("Die Reihenfolge fehlt.")
|
||||
.Must(ids => ids is null || (ids.Count <= LearningSet.MaxItems && ids.Distinct().Count() == ids.Count && ids.All(id => id != Guid.Empty)))
|
||||
.WithMessage("Ungültige Reihenfolge.");
|
||||
}
|
||||
|
||||
public sealed class SaveAnswerRequestValidator : AbstractValidator<SaveAnswerRequest>
|
||||
{
|
||||
public SaveAnswerRequestValidator() =>
|
||||
RuleFor(r => r.Value).MaximumLength(LearningAnswer.ValueMaxLength)
|
||||
.WithMessage($"Die Antwort darf höchstens {LearningAnswer.ValueMaxLength} Zeichen haben.");
|
||||
}
|
||||
|
||||
public sealed class ReviewAnswerRequestValidator : AbstractValidator<ReviewAnswerRequest>
|
||||
{
|
||||
public ReviewAnswerRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Verdict).Must(v => v is LearningVerdict.Correct or LearningVerdict.Partial or LearningVerdict.Wrong)
|
||||
.WithMessage("Bitte „richtig“, „teilweise“ oder „falsch“ wählen.");
|
||||
RuleFor(r => r.AwardedPoints).InclusiveBetween(1, LearningItem.MaxPoints).When(r => r.AwardedPoints is not null)
|
||||
.WithMessage("Ungültige Teilpunkte.");
|
||||
RuleFor(r => r.Reason).MaximumLength(LearningAnswer.ReasonMaxLength)
|
||||
.WithMessage($"Die Begründung darf höchstens {LearningAnswer.ReasonMaxLength} Zeichen haben.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ConfirmAttemptRequestValidator : AbstractValidator<ConfirmAttemptRequest>
|
||||
{
|
||||
public ConfirmAttemptRequestValidator() =>
|
||||
RuleFor(r => r.Note).MaximumLength(LearningAttempt.NoteMaxLength)
|
||||
.WithMessage($"Der Kommentar darf höchstens {LearningAttempt.NoteMaxLength} Zeichen haben.");
|
||||
}
|
||||
@@ -29,6 +29,8 @@ public sealed class PermissionMatrixTests
|
||||
{ Permission.FamilyChat, F, F, F, N }, // Gast: nur wenn freigegeben
|
||||
{ Permission.DirectMessages, F, F, R, N }, // Kind: nur an Familienmitglieder
|
||||
{ Permission.AiUse, F, F, N, N }, // KI nur für Eltern (ADR-0020)
|
||||
{ Permission.LearningManage, F, F, N, N }, // Übungsblätter verwalten und prüfen (ADR-0021)
|
||||
{ Permission.LearningSolve, F, F, R, N }, // Kind: nur eigene Blätter lösen (ADR-0021)
|
||||
{ Permission.UserManagement, F, N, N, N },
|
||||
{ Permission.CalendarManagement, F, N, N, N },
|
||||
{ Permission.SettingsManagement, F, N, N, N },
|
||||
|
||||
Reference in new issue
Block a user