feat: add learning API slice with attempts, review, points and dashboard tile

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 13:33:25 +02:00
1 parent 5f54d10223
commit 9c72a6f768
9 files changed
+1531 -2

No files matched your search

@@ -1,10 +1,12 @@
using System.Security.Claims;
using FamilyPlanner.Api.Features.Calendar;
using FamilyPlanner.Api.Features.Chat;
using FamilyPlanner.Api.Features.Learning;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Domain.Meals;
using FamilyPlanner.Domain.Tasks;
using FamilyPlanner.Infrastructure.Identity;
@@ -57,7 +59,61 @@ public static class DashboardEndpoints
? new DashboardChatDto(await chatAccess.TotalUnreadAsync(user, ct))
: null;
return TypedResults.Ok(new DashboardDto(today, meals, events, shopping, myTasks, familyTasks, chat));
var learning = await LoadLearningAsync(user, db, settingsProvider, today, ct);
return TypedResults.Ok(new DashboardDto(today, meals, events, shopping, myTasks, familyTasks, chat, learning));
}
private const int LearningTileItems = 5;
/// <summary>Lern-Kachel: Eltern sehen die Prüf-Warteschlange, Kinder ihre offenen Blätter; sonst keine Kachel.</summary>
private static async Task<DashboardLearningDto?> LoadLearningAsync(
ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, DateOnly today, CancellationToken ct)
{
var actor = await user.LearningActorAsync(settingsProvider);
if (actor.CanManage)
{
var open = await db.LearningSets.AsNoTracking()
.CountAsync(s => s.Status == LearningSetStatus.Assigned || s.Status == LearningSetStatus.InProgress, ct);
var queue = db.LearningAttempts.AsNoTracking()
.Where(a => a.Status == LearningAttemptStatus.Submitted)
.Join(db.LearningSets.AsNoTracking(), a => a.SetId, s => s.Id, (a, s) => new { Attempt = a, Set = s });
var toReview = await queue.CountAsync(ct);
var top = await queue.OrderBy(x => x.Attempt.SubmittedAt).Take(LearningTileItems)
.Select(x => new { AttemptId = x.Attempt.Id, x.Attempt.UserId, SetId = x.Set.Id, x.Set.Title, x.Set.Subject, x.Set.DueDate })
.ToListAsync(ct);
var names = await LearningAccess.DisplayNamesAsync(db, top.Select(t => t.UserId), ct);
return new DashboardLearningDto(
open,
toReview,
true,
top.Select(t => new DashboardLearningItemDto(t.SetId, t.AttemptId, t.Title, t.Subject, t.DueDate, false, names.GetValueOrDefault(t.UserId))).ToList());
}
if (actor.Solve == AccessLevel.None)
{
return null;
}
var me = actor.UserId;
var mine = await db.LearningSets.AsNoTracking()
.Where(s => s.TargetUserId == me && (s.Status == LearningSetStatus.Assigned || s.Status == LearningSetStatus.InProgress))
.Select(s => new
{
s.Id,
s.Title,
s.Subject,
s.DueDate,
RunningAttemptId = s.Attempts.Where(a => a.UserId == me && a.Status == LearningAttemptStatus.InProgress).Select(a => (Guid?)a.Id).FirstOrDefault(),
})
.ToListAsync(ct);
return new DashboardLearningDto(
mine.Count,
0,
false,
mine.OrderBy(s => s.DueDate ?? DateOnly.MaxValue).ThenBy(s => s.Title).Take(LearningTileItems)
.Select(s => new DashboardLearningItemDto(s.Id, s.RunningAttemptId, s.Title, s.Subject, s.DueDate, s.DueDate is { } due && due < today, null))
.ToList());
}
private static async Task<List<DashboardMealDto>> LoadMealsAsync(AppDbContext db, IEnumerable<string> configuredTypes, DateOnly today, CancellationToken ct)
@@ -0,0 +1,170 @@
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Domain.Tasks;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared.Learning;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>
/// Gemeinsame Abläufe der Versuche: Abgabe (auch automatisch nach Zeitablauf), Zeitablauf prüfen und Punkte im Punktebuch abgleichen.
/// Wird von den Endpunkten und vom Hintergrunddienst genutzt, damit die Regeln nur einmal existieren.
/// </summary>
public sealed class LearningAttemptService(AppDbContext db, ISettingsProvider settingsProvider, LearningNotifier notifier, TimeProvider timeProvider)
{
private const int MaxFinalizeBatch = 50;
public DateTimeOffset Now => timeProvider.GetUtcNow();
/// <summary>Alle Antworten fertig bewerten (leere = falsch; Elternbewertungen bleiben) und den Versuch als abgegeben markieren.</summary>
public void Submit(LearningAttempt attempt, LearningSet set, DateTimeOffset now)
{
var items = set.Items.ToDictionary(i => i.Id);
foreach (var id in LearningRules.ParseOrder(attempt.ItemOrder))
{
if (!items.TryGetValue(id, out var item))
{
continue;
}
var answer = attempt.Answers.SingleOrDefault(a => a.ItemId == id);
if (answer is null)
{
answer = new LearningAnswer { AttemptId = attempt.Id, ItemId = id };
attempt.Answers.Add(answer);
}
if (answer.Grader == LearningGrader.Parent)
{
continue;
}
var result = LearningGrading.Grade(item, answer.Value);
answer.Verdict = result.Verdict;
answer.AwardedPoints = result.AwardedPoints;
answer.Reason = result.Reason;
answer.Grader = LearningGrader.Auto;
answer.GradedAt = now;
}
attempt.Status = LearningAttemptStatus.Submitted;
attempt.SubmittedAt = LearningRules.SubmittedAtFor(attempt.DeadlineAt, now);
attempt.AutoSubmitted = attempt.DeadlineAt is { } deadline && now > deadline;
attempt.LastActivityAt = now;
RefreshStatus(set);
}
/// <summary>Status des Blatts aus den Versuchen neu bestimmen.</summary>
public static void RefreshStatus(LearningSet set) =>
set.Status = LearningRules.DeriveStatus(set.Status, set.Attempts.Select(a => a.Status).ToList());
/// <summary>
/// Gibt laufende Versuche ab, deren Frist samt Toleranz vorbei ist (ADR-0021 Punkt 12) – beim nächsten Zugriff und per Hintergrunddienst.
/// Mit <paramref name="userId"/> nur die Versuche dieses Kindes. Liefert die Zahl abgegebener Versuche.
/// </summary>
public async Task<int> FinalizeExpiredAsync(Guid? userId, CancellationToken ct)
{
var now = Now;
var cutoff = now - LearningRules.DeadlineTolerance;
var query = db.LearningAttempts
.Where(a => a.Status == LearningAttemptStatus.InProgress && a.DeadlineAt != null && a.DeadlineAt < cutoff);
if (userId is { } id)
{
query = query.Where(a => a.UserId == id);
}
var ids = await query.OrderBy(a => a.DeadlineAt).Select(a => a.Id).Take(MaxFinalizeBatch).ToListAsync(ct);
var finalized = 0;
foreach (var attemptId in ids)
{
if (await TryFinalizeAsync(attemptId, ct))
{
finalized++;
}
}
return finalized;
}
/// <summary>Einen abgelaufenen Versuch abgeben; <c>false</c>, wenn er inzwischen anders abgegeben wurde oder noch läuft.</summary>
public async Task<bool> TryFinalizeAsync(Guid attemptId, CancellationToken ct)
{
for (var attempt = 1; attempt <= 3; attempt++)
{
var entity = await db.LearningAttempts.Include(a => a.Answers).SingleOrDefaultAsync(a => a.Id == attemptId, ct);
if (entity is null || entity.Status != LearningAttemptStatus.InProgress)
{
return false;
}
var now = Now;
if (!LearningRules.MustAutoSubmit(entity.DeadlineAt, now))
{
return false;
}
var set = await db.LearningSets.Include(s => s.Items).Include(s => s.Attempts).AsSplitQuery()
.SingleOrDefaultAsync(s => s.Id == entity.SetId, ct);
if (set is null)
{
return false; // Blatt gelöscht – der Versuch bleibt unverändert
}
var tracked = set.Attempts.Single(a => a.Id == entity.Id);
Submit(tracked, set, now);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// Gleichzeitig vom Kind abgegeben oder neue Antwort gespeichert: Zustand neu laden und erneut bewerten.
db.ChangeTracker.Clear();
continue;
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.Submitted, ct);
return true;
}
return false;
}
/// <summary>
/// Gleicht das Punktebuch mit den bestätigten Versuchen ab (ADR-0021 Punkt 10): Ziel ist die <b>beste</b> bestätigte Wertung, gebucht wird
/// nur die Differenz zum bisher gebuchten Stand (Gutschrift, Verbesserung oder Rückbuchung). Idempotent, da vom gebuchten Stand ausgegangen wird.
/// Ist das Punktesystem abgeschaltet, wird nichts gebucht. Liefert die Differenz und den neuen Stand; die Buchung ist vorgemerkt, nicht gespeichert.
/// </summary>
public async Task<(int Delta, int Credited)> SyncPointsAsync(LearningSet set, string reasonOnDecrease, CancellationToken ct)
{
var booked = await db.PointsLedger.Where(p => p.LearningSetId == set.Id).SumAsync(p => (int?)p.Points, ct) ?? 0;
var settings = await settingsProvider.GetAsync(ct);
if (!settings.ChorePointsEnabled)
{
return (0, booked);
}
var target = LearningRules.TargetCredit(
set.TotalPoints,
set.Attempts.Where(a => a.Status == LearningAttemptStatus.Reviewed && a.EarnedPoints is not null && a.TotalItemPoints is not null)
.Select(a => (a.EarnedPoints!.Value, a.TotalItemPoints!.Value)));
var delta = LearningRules.CreditDelta(target, booked);
if (delta == 0)
{
return (0, booked);
}
var reason = delta < 0
? reasonOnDecrease
: booked == 0 ? $"Lernblatt „{set.Title}“" : $"Lernblatt „{set.Title}“ verbessert";
db.PointsLedger.Add(new PointsLedgerEntry
{
UserId = set.TargetUserId,
Points = delta,
LearningSetId = set.Id,
Reason = reason.Length <= PointsLedgerEntry.ReasonMaxLength ? reason : reason[..(PointsLedgerEntry.ReasonMaxLength - 1)] + "…",
});
return (delta, target);
}
}
@@ -0,0 +1,13 @@
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>Lernaufgaben (ADR-0021): Eltern unter <c>/api/learning</c> (LearningManage), Kinder unter <c>/api/learning/my</c> (LearningSolve).</summary>
public static class LearningEndpoints
{
public static IEndpointRouteBuilder MapLearningEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/learning").WithTags("Lernen");
LearningManageEndpoints.Map(group);
LearningSolveEndpoints.Map(group);
return app;
}
}
@@ -0,0 +1,34 @@
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>
/// Gibt Versuche mit abgelaufenem Zeitlimit automatisch ab (ADR-0021 Punkt 12), auch wenn das Kind die App geschlossen hat –
/// damit landen sie in der Prüf-Warteschlange. Zusätzlich passiert dasselbe lazy bei jedem Zugriff (<see cref="LearningAttemptService"/>).
/// </summary>
internal sealed class LearningExpiryService(IServiceScopeFactory scopeFactory, TimeProvider timeProvider, ILogger<LearningExpiryService> logger)
: BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(Interval, timeProvider);
do
{
try
{
await SweepAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Abgelaufene Lernversuche konnten nicht abgegeben werden.");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
internal async Task<int> SweepAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<LearningAttemptService>().FinalizeExpiredAsync(null, ct);
}
}
@@ -0,0 +1,694 @@
using System.Security.Claims;
using FamilyPlanner.Api.Features.Tasks;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Infrastructure.Audit;
using FamilyPlanner.Infrastructure.Identity;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared.Learning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>Elternseite der Lernaufgaben (<c>LearningManage</c>): Blätter und Aufgaben, Zuweisen, Prüf-Warteschlange, Bestätigen (ADR-0021).</summary>
internal static class LearningManageEndpoints
{
private const int MaxReviewQueue = 100;
public static void Map(RouteGroupBuilder group)
{
var manage = group.MapGroup(string.Empty).RequirePermission(Permission.LearningManage);
manage.MapGet("/sets", ListSetsAsync);
manage.MapPost("/sets", CreateSetAsync).Validate<CreateLearningSetRequest>();
manage.MapGet("/sets/{id:guid}", GetSetAsync);
manage.MapPut("/sets/{id:guid}", UpdateSetAsync).Validate<UpdateLearningSetRequest>();
manage.MapDelete("/sets/{id:guid}", DeleteSetAsync);
manage.MapPost("/sets/{id:guid}/assign", AssignAsync);
manage.MapPost("/sets/{id:guid}/unassign", UnassignAsync);
manage.MapPost("/sets/{id:guid}/items", AddItemAsync).Validate<LearningItemInput>();
manage.MapPut("/sets/{id:guid}/items/{itemId:guid}", UpdateItemAsync).Validate<UpdateLearningItemRequest>();
manage.MapDelete("/sets/{id:guid}/items/{itemId:guid}", DeleteItemAsync);
manage.MapPost("/sets/{id:guid}/items/order", ReorderItemsAsync).Validate<ReorderLearningItemsRequest>();
manage.MapGet("/review", ReviewQueueAsync);
manage.MapGet("/attempts/{attemptId:guid}", GetAttemptAsync);
manage.MapPut("/attempts/{attemptId:guid}/answers/{itemId:guid}/review", ReviewAnswerAsync).Validate<ReviewAnswerRequest>();
manage.MapPost("/attempts/{attemptId:guid}/confirm", ConfirmAsync).Validate<ConfirmAttemptRequest>();
manage.MapPost("/attempts/{attemptId:guid}/reopen", ReopenAsync);
}
// ---------- Blätter ----------
private static async Task<Ok<List<LearningSetDto>>> ListSetsAsync(AppDbContext db, LearningAttemptService service, CancellationToken ct)
{
await service.FinalizeExpiredAsync(null, ct);
var rows = await db.LearningSets.AsNoTracking()
.OrderByDescending(s => s.UpdatedAt)
.Select(s => new
{
Set = s,
ItemCount = s.Items.Count(),
AttemptCount = s.Attempts.Count(),
Pending = s.Attempts.Count(a => a.Status == LearningAttemptStatus.Submitted),
})
.ToListAsync(ct);
var credited = await CreditedAsync(db, rows.Select(r => r.Set.Id), ct);
return TypedResults.Ok(rows
.Select(r => LearningMapper.ToDto(r.Set, r.ItemCount, r.AttemptCount, r.Pending, credited.GetValueOrDefault(r.Set.Id)))
.ToList());
}
private static async Task<Results<Ok<LearningSetDetailDto>, NotFound>> GetSetAsync(Guid id, AppDbContext db, LearningAttemptService service, CancellationToken ct)
{
await service.FinalizeExpiredAsync(null, ct);
return await LoadDetailAsync(db, id, ct) is { } detail ? TypedResults.Ok(detail) : TypedResults.NotFound();
}
private static async Task<Results<Created<LearningSetDetailDto>, ValidationProblem>> CreateSetAsync(
CreateLearningSetRequest request, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
if (await CheckTargetAsync(db, request.TargetUserId, ct) is { } invalid)
{
return invalid;
}
var set = new LearningSet();
ApplySet(set, request);
var order = 0;
foreach (var input in request.Items ?? [])
{
var item = LearningValidation.ToItem(input);
item.SortOrder = order++;
set.Items.Add(item);
}
db.LearningSets.Add(set);
await db.SaveChangesAsync(ct);
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.SetChanged, ct);
var detail = (await LoadDetailAsync(db, set.Id, ct))!;
return TypedResults.Created($"/api/learning/sets/{set.Id}", detail);
}
private static async Task<IResult> UpdateSetAsync(
Guid id, UpdateLearningSetRequest request, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var set = await db.LearningSets.Include(s => s.Items).Include(s => s.Attempts).AsSplitQuery().SingleOrDefaultAsync(s => s.Id == id, ct);
if (set is null)
{
return TypedResults.NotFound();
}
if (request.Version is { Length: > 0 } version && !version.AsSpan().SequenceEqual(set.RowVersion))
{
return TypedResults.Conflict(await ConflictDetailAsync(db, id, ct));
}
if (request.TargetUserId != set.TargetUserId && await CheckTargetAsync(db, request.TargetUserId, ct) is { } invalid)
{
return invalid;
}
if (set.Attempts.Count > 0 && LockedFieldChanged(set, request) is { } locked)
{
return EndpointHelpers.Conflict($"{locked} lässt sich nach dem ersten Versuch nicht mehr ändern.");
}
var previousTarget = set.TargetUserId;
ApplySet(set, request);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
db.ChangeTracker.Clear();
return TypedResults.Conflict(await ConflictDetailAsync(db, id, ct));
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.SetChanged, ct, previousTarget != set.TargetUserId ? previousTarget : null);
return TypedResults.Ok(await LoadDetailAsync(db, id, ct));
}
private static async Task<NoContent> DeleteSetAsync(Guid id, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var set = await db.LearningSets.SingleOrDefaultAsync(s => s.Id == id, ct);
if (set is null)
{
return TypedResults.NoContent(); // idempotent
}
// Bereits gutgeschriebene Punkte bleiben (wie beim Löschen erledigter Ämtchen, ADR-0014); Korrekturen über die Punkteverwaltung.
db.LearningSets.Remove(set);
await db.SaveChangesAsync(ct);
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.Deleted, ct);
return TypedResults.NoContent();
}
private static async Task<IResult> AssignAsync(Guid id, AppDbContext db, LearningNotifier notifier, TimeProvider time, CancellationToken ct)
{
var set = await db.LearningSets.Include(s => s.Items).SingleOrDefaultAsync(s => s.Id == id, ct);
if (set is null)
{
return TypedResults.NotFound();
}
if (LearningRules.CheckAssignable(set.Status, set.Items) is { } error)
{
return EndpointHelpers.Conflict(error);
}
if (await CheckTargetAsync(db, set.TargetUserId, ct) is { } invalid)
{
return invalid;
}
set.Status = LearningSetStatus.Assigned;
set.AssignedAt = time.GetUtcNow();
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
return EndpointHelpers.Conflict("Das Blatt wurde inzwischen geändert. Bitte neu laden.");
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.Assigned, ct);
return TypedResults.Ok(await LoadDetailAsync(db, id, ct));
}
private static async Task<IResult> UnassignAsync(Guid id, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var set = await db.LearningSets.Include(s => s.Attempts).SingleOrDefaultAsync(s => s.Id == id, ct);
if (set is null)
{
return TypedResults.NotFound();
}
if (set.Status != LearningSetStatus.Assigned || set.Attempts.Count > 0)
{
return EndpointHelpers.Conflict("Nur Blätter ohne Versuch lassen sich wieder zum Entwurf machen.");
}
set.Status = LearningSetStatus.Draft;
set.AssignedAt = null;
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
return EndpointHelpers.Conflict("Das Blatt wurde inzwischen geändert. Bitte neu laden.");
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.SetChanged, ct);
return TypedResults.Ok(await LoadDetailAsync(db, id, ct));
}
// ---------- Aufgaben ----------
private static async Task<IResult> AddItemAsync(Guid id, LearningItemInput request, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var (set, error) = await LoadEditableAsync(db, id, ct);
if (set is null)
{
return error!;
}
if (set.Items.Count >= LearningSet.MaxItems)
{
return EndpointHelpers.Conflict($"Ein Blatt kann höchstens {LearningSet.MaxItems} Aufgaben haben.");
}
var item = LearningValidation.ToItem(request);
item.SortOrder = set.Items.Count == 0 ? 0 : set.Items.Max(i => i.SortOrder) + 1;
set.Items.Add(item);
return await SaveItemsAsync(db, set, notifier, ct);
}
private static async Task<IResult> UpdateItemAsync(
Guid id, Guid itemId, UpdateLearningItemRequest request, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var (set, error) = await LoadEditableAsync(db, id, ct);
if (set is null)
{
return error!;
}
if (set.Items.SingleOrDefault(i => i.Id == itemId) is not { } item)
{
return TypedResults.NotFound();
}
if (request.Version is { Length: > 0 } version && !version.AsSpan().SequenceEqual(item.RowVersion))
{
return TypedResults.Conflict(await ConflictDetailAsync(db, id, ct));
}
var fields = LearningValidation.ToItem(request);
item.Question = fields.Question;
item.AnswerType = fields.AnswerType;
item.CorrectAnswer = fields.CorrectAnswer;
item.NumberTolerance = fields.NumberTolerance;
item.Options = fields.Options;
item.Hint = fields.Hint;
item.Explanation = fields.Explanation;
item.Difficulty = fields.Difficulty;
item.Points = fields.Points;
return await SaveItemsAsync(db, set, notifier, ct);
}
private static async Task<IResult> DeleteItemAsync(Guid id, Guid itemId, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var (set, error) = await LoadEditableAsync(db, id, ct);
if (set is null)
{
return error!;
}
if (set.Items.SingleOrDefault(i => i.Id == itemId) is not { } item)
{
return TypedResults.Ok(await LoadDetailAsync(db, id, ct)); // idempotent
}
db.LearningItems.Remove(item); // Soft Delete über den Interceptor
var order = 0;
foreach (var remaining in set.Items.Where(i => i.Id != itemId).OrderBy(i => i.SortOrder))
{
remaining.SortOrder = order++;
}
return await SaveItemsAsync(db, set, notifier, ct);
}
private static async Task<IResult> ReorderItemsAsync(
Guid id, ReorderLearningItemsRequest request, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var (set, error) = await LoadEditableAsync(db, id, ct);
if (set is null)
{
return error!;
}
if (request.ItemIds.Count != set.Items.Count || !set.Items.Select(i => i.Id).ToHashSet().SetEquals(request.ItemIds))
{
return EndpointHelpers.Conflict("Die Reihenfolge passt nicht zu den Aufgaben des Blatts. Bitte neu laden.");
}
for (var index = 0; index < request.ItemIds.Count; index++)
{
set.Items.Single(i => i.Id == request.ItemIds[index]).SortOrder = index;
}
return await SaveItemsAsync(db, set, notifier, ct);
}
/// <summary>Blatt mit Aufgaben laden; Aufgaben sind nur änderbar, solange es keinen Versuch gibt.</summary>
private static async Task<(LearningSet? Set, IResult? Error)> LoadEditableAsync(AppDbContext db, Guid id, CancellationToken ct)
{
var set = await db.LearningSets.Include(s => s.Items).Include(s => s.Attempts).AsSplitQuery().SingleOrDefaultAsync(s => s.Id == id, ct);
if (set is null)
{
return (null, TypedResults.NotFound());
}
return LearningRules.CanEditItems(set.Attempts.Count)
? (set, null)
: (null, EndpointHelpers.Conflict("Nach dem ersten Versuch lassen sich die Aufgaben nicht mehr ändern."));
}
private static async Task<IResult> SaveItemsAsync(AppDbContext db, LearningSet set, LearningNotifier notifier, CancellationToken ct)
{
set.ItemsRevision++; // ändert die Zeile des Blatts: kollidiert mit gleichzeitigem Start eines Versuchs oder Bearbeiten
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
db.ChangeTracker.Clear();
return TypedResults.Conflict(await ConflictDetailAsync(db, set.Id, ct));
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.SetChanged, ct);
return TypedResults.Ok(await LoadDetailAsync(db, set.Id, ct));
}
// ---------- Prüfung ----------
private static async Task<Ok<List<LearningReviewQueueItemDto>>> ReviewQueueAsync(AppDbContext db, LearningAttemptService service, CancellationToken ct)
{
await service.FinalizeExpiredAsync(null, ct);
var attempts = await db.LearningAttempts.AsNoTracking().Include(a => a.Answers)
.Where(a => a.Status == LearningAttemptStatus.Submitted)
.Where(a => db.LearningSets.Any(s => s.Id == a.SetId)) // gelöschte Blätter (Soft Delete) nicht anzeigen
.OrderBy(a => a.SubmittedAt)
.Take(MaxReviewQueue)
.ToListAsync(ct);
var setIds = attempts.Select(a => a.SetId).Distinct().ToList();
var sets = await db.LearningSets.AsNoTracking().Include(s => s.Items).Where(s => setIds.Contains(s.Id)).AsSplitQuery().ToDictionaryAsync(s => s.Id, ct);
var names = await LearningAccess.DisplayNamesAsync(db, attempts.Select(a => a.UserId), ct);
var rows = new List<LearningReviewQueueItemDto>();
foreach (var attempt in attempts)
{
var set = sets[attempt.SetId];
var items = set.Items.ToDictionary(i => i.Id);
var score = LearningMapper.ScoreOf(attempt, items);
rows.Add(new LearningReviewQueueItemDto(
attempt.Id, set.Id, set.Title, set.Subject, set.Mode, attempt.UserId, names.GetValueOrDefault(attempt.UserId, string.Empty),
attempt.Number, attempt.SubmittedAt ?? attempt.StartedAt, attempt.AutoSubmitted, score.OpenCount, score.Earned,
LearningMapper.TotalPointsOf(attempt, items)));
}
return TypedResults.Ok(rows);
}
private static async Task<Results<Ok<LearningAttemptReviewDto>, NotFound, ProblemHttpResult>> GetAttemptAsync(
Guid attemptId, AppDbContext db, LearningAttemptService service, CancellationToken ct)
{
await service.FinalizeExpiredAsync(null, ct);
var loaded = await LoadAttemptAsync(db, attemptId, tracking: false, ct);
if (loaded is null)
{
return TypedResults.NotFound();
}
if (loaded.Attempt.Status == LearningAttemptStatus.InProgress)
{
return EndpointHelpers.Conflict("Der Versuch läuft noch und wurde nicht abgegeben.");
}
return TypedResults.Ok(await ToReviewDtoAsync(db, loaded.Attempt, loaded.Set, ct));
}
private static async Task<IResult> ReviewAnswerAsync(
Guid attemptId, Guid itemId, ReviewAnswerRequest request, AppDbContext db, TimeProvider time, CancellationToken ct)
{
var loaded = await LoadAttemptAsync(db, attemptId, tracking: true, ct);
if (loaded is null || loaded.Set.Items.SingleOrDefault(i => i.Id == itemId) is not { } item)
{
return TypedResults.NotFound();
}
var attempt = loaded.Attempt;
if (attempt.Status != LearningAttemptStatus.Submitted)
{
return EndpointHelpers.Conflict(attempt.Status == LearningAttemptStatus.Reviewed
? "Der Versuch ist bereits bestätigt. Bitte zuerst erneut öffnen."
: "Der Versuch wurde noch nicht abgegeben.");
}
var (points, error) = LearningRules.NormalizeParentVerdict(request.Verdict, request.AwardedPoints, item.Points);
if (error is not null)
{
return TypedResults.ValidationProblem(
new Dictionary<string, string[]> { [nameof(ReviewAnswerRequest.AwardedPoints)] = [error] }, title: "Eingaben prüfen");
}
var now = time.GetUtcNow();
var answer = attempt.Answers.SingleOrDefault(a => a.ItemId == itemId);
if (answer is null)
{
answer = new LearningAnswer { AttemptId = attempt.Id, ItemId = itemId };
attempt.Answers.Add(answer);
}
answer.Verdict = request.Verdict;
answer.AwardedPoints = points;
answer.Grader = LearningGrader.Parent;
answer.Reason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
answer.GradedAt = now;
attempt.LastActivityAt = now;
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
return EndpointHelpers.Conflict("Der Versuch wurde inzwischen geändert. Bitte neu laden.");
}
return TypedResults.Ok(await ToReviewDtoAsync(db, attempt, loaded.Set, ct));
}
private static async Task<IResult> ConfirmAsync(
Guid attemptId,
ConfirmAttemptRequest request,
ClaimsPrincipal user,
AppDbContext db,
LearningAttemptService service,
LearningNotifier notifier,
TaskNotifier taskNotifier,
IAuditLogger audit,
CancellationToken ct)
{
var loaded = await LoadAttemptAsync(db, attemptId, tracking: true, ct);
if (loaded is null)
{
return TypedResults.NotFound();
}
var (attempt, set) = (loaded.Attempt, loaded.Set);
if (attempt.Status == LearningAttemptStatus.Reviewed)
{
return TypedResults.Ok(await ResultAsync(db, attempt, set, 0, ct)); // idempotent
}
if (attempt.Status != LearningAttemptStatus.Submitted)
{
return EndpointHelpers.Conflict("Der Versuch wurde noch nicht abgegeben.");
}
var items = set.Items.ToDictionary(i => i.Id);
var score = LearningMapper.ScoreOf(attempt, items);
if (score.OpenCount > 0)
{
return EndpointHelpers.Conflict($"Noch {score.OpenCount} Antwort(en) ohne Bewertung. Bitte zuerst alle Antworten bewerten.");
}
var now = service.Now;
var total = LearningMapper.TotalPointsOf(attempt, items);
attempt.Status = LearningAttemptStatus.Reviewed;
attempt.ReviewedAt = now;
attempt.ReviewedBy = user.UserId();
attempt.EarnedPoints = score.Earned;
attempt.TotalItemPoints = total;
attempt.Grade = set.Mode == LearningMode.Exam ? LearningGrades.GradeFor(score.Earned, total, LearningMapper.Thresholds(set)) : null;
attempt.ReviewNote = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();
attempt.LastActivityAt = now;
LearningAttemptService.RefreshStatus(set);
var (delta, credited) = await service.SyncPointsAsync(set, $"Rückbuchung: Lernblatt „{set.Title}“", ct);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
return EndpointHelpers.Conflict("Der Versuch wurde inzwischen geändert. Bitte neu laden.");
}
await PublishAsync(set, delta, LearningChangeKind.Reviewed, user, notifier, taskNotifier, audit, db, ct);
return TypedResults.Ok(await ResultAsync(db, attempt, set, delta, ct, credited));
}
private static async Task<IResult> ReopenAsync(
Guid attemptId,
ClaimsPrincipal user,
AppDbContext db,
LearningAttemptService service,
LearningNotifier notifier,
TaskNotifier taskNotifier,
IAuditLogger audit,
CancellationToken ct)
{
var loaded = await LoadAttemptAsync(db, attemptId, tracking: true, ct);
if (loaded is null)
{
return TypedResults.NotFound();
}
var (attempt, set) = (loaded.Attempt, loaded.Set);
if (attempt.Status == LearningAttemptStatus.Submitted)
{
return TypedResults.Ok(await ResultAsync(db, attempt, set, 0, ct)); // idempotent
}
if (attempt.Status != LearningAttemptStatus.Reviewed)
{
return EndpointHelpers.Conflict("Nur bestätigte Versuche lassen sich erneut öffnen.");
}
attempt.Status = LearningAttemptStatus.Submitted;
attempt.ReviewedAt = null;
attempt.ReviewedBy = null;
attempt.EarnedPoints = null;
attempt.TotalItemPoints = null;
attempt.Grade = null;
attempt.LastActivityAt = service.Now;
LearningAttemptService.RefreshStatus(set);
var (delta, credited) = await service.SyncPointsAsync(set, $"Rückbuchung: Lernblatt „{set.Title}“ wieder geöffnet", ct);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
return EndpointHelpers.Conflict("Der Versuch wurde inzwischen geändert. Bitte neu laden.");
}
await PublishAsync(set, delta, LearningChangeKind.SetChanged, user, notifier, taskNotifier, audit, db, ct);
return TypedResults.Ok(await ResultAsync(db, attempt, set, delta, ct, credited));
}
// ---------- Hilfen ----------
private sealed record LoadedAttempt(LearningAttempt Attempt, LearningSet Set);
/// <summary>Versuch mit Antworten und das zugehörige (nicht gelöschte) Blatt mit Aufgaben und allen Versuchen.</summary>
private static async Task<LoadedAttempt?> LoadAttemptAsync(AppDbContext db, Guid attemptId, bool tracking, CancellationToken ct)
{
var setId = await db.LearningAttempts.Where(a => a.Id == attemptId).Select(a => (Guid?)a.SetId).SingleOrDefaultAsync(ct);
if (setId is null)
{
return null;
}
var setQuery = db.LearningSets.Include(s => s.Items).Include(s => s.Attempts).ThenInclude(a => a.Answers).AsSplitQuery();
if (!tracking)
{
setQuery = setQuery.AsNoTracking();
}
var set = await setQuery.SingleOrDefaultAsync(s => s.Id == setId, ct);
var attempt = set?.Attempts.SingleOrDefault(a => a.Id == attemptId);
return set is null || attempt is null ? null : new LoadedAttempt(attempt, set);
}
private static async Task<LearningAttemptReviewDto> ToReviewDtoAsync(AppDbContext db, LearningAttempt attempt, LearningSet set, CancellationToken ct)
{
var names = await LearningAccess.DisplayNamesAsync(db, [attempt.UserId], ct);
var credited = (await CreditedAsync(db, [set.Id], ct)).GetValueOrDefault(set.Id);
return LearningMapper.ToReviewDto(attempt, set, names.GetValueOrDefault(attempt.UserId, string.Empty), credited);
}
private static async Task<LearningReviewResultDto> ResultAsync(
AppDbContext db, LearningAttempt attempt, LearningSet set, int delta, CancellationToken ct, int? credited = null)
{
var dto = await ToReviewDtoAsync(db, attempt, set, ct);
return new LearningReviewResultDto(credited is null ? dto : dto with { PointsCredited = credited.Value }, delta, credited ?? dto.PointsCredited);
}
private static async Task PublishAsync(
LearningSet set,
int delta,
LearningChangeKind kind,
ClaimsPrincipal user,
LearningNotifier notifier,
TaskNotifier taskNotifier,
IAuditLogger audit,
AppDbContext db,
CancellationToken ct)
{
if (delta != 0)
{
var names = await LearningAccess.DisplayNamesAsync(db, [set.TargetUserId], ct);
var child = names.GetValueOrDefault(set.TargetUserId, string.Empty);
await audit.WriteAsync(
user.ActorName(),
delta > 0 ? AuditActions.LearningPointsCredited : AuditActions.LearningPointsReversed,
"LearningSet",
set.Id.ToString(),
set.Title,
$"{(delta > 0 ? "+" : string.Empty)}{delta} Punkte für {child}",
ct);
await taskNotifier.PointsChangedAsync(ct);
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, kind, ct);
}
private static async Task<Dictionary<Guid, int>> CreditedAsync(AppDbContext db, IEnumerable<Guid> setIds, CancellationToken ct)
{
var ids = setIds.Distinct().Cast<Guid?>().ToList();
return await db.PointsLedger.AsNoTracking()
.Where(p => ids.Contains(p.LearningSetId))
.GroupBy(p => p.LearningSetId!.Value)
.Select(g => new { Id = g.Key, Points = g.Sum(p => p.Points) })
.ToDictionaryAsync(x => x.Id, x => x.Points, ct);
}
internal static async Task<LearningSetDetailDto?> LoadDetailAsync(AppDbContext db, Guid id, CancellationToken ct)
{
var set = await db.LearningSets.AsNoTracking().Include(s => s.Items).Include(s => s.Attempts).AsSplitQuery().SingleOrDefaultAsync(s => s.Id == id, ct);
return set is null ? null : LearningMapper.ToDetail(set, (await CreditedAsync(db, [id], ct)).GetValueOrDefault(id));
}
private static async Task<LearningSetDetailDto> ConflictDetailAsync(AppDbContext db, Guid id, CancellationToken ct) =>
(await LoadDetailAsync(db, id, ct))!;
/// <summary>Das Ziel muss ein aktives Kind sein (Rolle „Kind“).</summary>
private static async Task<ValidationProblem?> CheckTargetAsync(AppDbContext db, Guid userId, CancellationToken ct)
{
var roles = await db.GetRoleMapAsync(ct);
var active = await db.Users.AnyAsync(u => u.Id == userId && u.IsActive, ct);
return active && roles.GetValueOrDefault(userId) == Roles.Child
? null
: TypedResults.ValidationProblem(
new Dictionary<string, string[]> { [nameof(ILearningSetFields.TargetUserId)] = ["Übungsblätter lassen sich nur Kindern zuweisen."] },
title: "Eingaben prüfen");
}
private static void ApplySet(LearningSet set, ILearningSetFields fields)
{
set.Title = fields.Title.Trim();
set.Subject = fields.Subject;
set.GradeLevel = fields.GradeLevel;
set.TargetUserId = fields.TargetUserId;
set.DueDate = fields.DueDate;
set.Mode = fields.Mode;
set.MaxAttempts = fields.MaxAttempts;
set.TimeLimitMinutes = fields.TimeLimitMinutes;
set.ShowSolutionsAfterSubmit = fields.ShowSolutionsAfterSubmit;
set.ShuffleItems = fields.ShuffleItems;
set.TotalPoints = fields.TotalPoints;
LearningGrades.TryParse(fields.GradeThresholds, out var thresholds, out _);
set.GradeThresholds = LearningGrades.Format(thresholds);
}
/// <summary>Nach dem ersten Versuch sind Ziel, Modus, Mischen, Punkte und Notenschlüssel fest (sonst ändern sich Wertungen nachträglich).</summary>
private static string? LockedFieldChanged(LearningSet set, ILearningSetFields fields)
{
LearningGrades.TryParse(fields.GradeThresholds, out var thresholds, out _);
if (fields.TargetUserId != set.TargetUserId)
{
return "Das Kind";
}
if (fields.Mode != set.Mode)
{
return "Der Modus";
}
if (fields.ShuffleItems != set.ShuffleItems)
{
return "Das Mischen der Aufgaben";
}
if (fields.TotalPoints != set.TotalPoints)
{
return "Die Gesamtpunktzahl";
}
return LearningGrades.Format(thresholds) != set.GradeThresholds ? "Der Notenschlüssel" : null;
}
}
@@ -0,0 +1,320 @@
using System.Security.Claims;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared.Learning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>
/// Kinderseite der Lernaufgaben (<c>LearningSolve</c>): eigene Blätter, Versuch starten, Antworten speichern, abgeben, Ergebnis (ADR-0021).
/// Fremde Blätter und Versuche antworten mit 404; Entwürfe sind unsichtbar. Alle Antworten entstehen über <see cref="LearningMapper"/>
/// (Sichtbarkeit nach <see cref="LearningRules.VisibilityFor"/>).
/// </summary>
internal static class LearningSolveEndpoints
{
private const int MaxAttempts = 3;
public static void Map(RouteGroupBuilder group)
{
var solve = group.MapGroup("/my").RequirePermission(Permission.LearningSolve);
solve.MapGet("/", ListAsync);
solve.MapPost("/{setId:guid}/attempts", StartAsync);
solve.MapGet("/attempts/{attemptId:guid}", GetAttemptAsync);
solve.MapPut("/attempts/{attemptId:guid}/answers/{itemId:guid}", SaveAnswerAsync).Validate<SaveAnswerRequest>();
solve.MapPost("/attempts/{attemptId:guid}/submit", SubmitAsync);
}
private static async Task<Ok<List<MyLearningSetDto>>> ListAsync(
ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, LearningAttemptService service, CancellationToken ct)
{
var actor = await user.LearningActorAsync(settingsProvider);
await service.FinalizeExpiredAsync(actor.UserId, ct);
var me = actor.UserId;
var sets = await db.LearningSets.AsNoTracking()
.Include(s => s.Items)
.Include(s => s.Attempts.Where(a => a.UserId == me)).ThenInclude(a => a.Answers)
.Where(s => s.TargetUserId == me && s.Status != LearningSetStatus.Draft)
.AsSplitQuery()
.ToListAsync(ct);
sets = sets.Where(s => LearningRules.CanSolve(actor, s.TargetUserId, s.Status)).ToList();
var ids = sets.Select(s => s.Id).Cast<Guid?>().ToList();
var credited = await db.PointsLedger.AsNoTracking()
.Where(p => ids.Contains(p.LearningSetId))
.GroupBy(p => p.LearningSetId!.Value)
.Select(g => new { Id = g.Key, Points = g.Sum(p => p.Points) })
.ToDictionaryAsync(x => x.Id, x => x.Points, ct);
var rows = sets
.OrderBy(s => s.DueDate ?? DateOnly.MaxValue)
.ThenBy(s => s.Title, StringComparer.CurrentCultureIgnoreCase)
.Select(set =>
{
var items = set.Items.ToDictionary(i => i.Id);
var statuses = set.Attempts.Select(a => a.Status).ToList();
return new MyLearningSetDto(
set.Id,
set.Title,
set.Subject,
set.GradeLevel,
set.DueDate,
set.Status,
set.Mode,
set.TimeLimitMinutes,
set.Items.Count,
set.TotalPoints,
set.MaxAttempts,
set.Attempts.Count,
LearningRules.CanStartAttempt(set.Status, set.MaxAttempts, statuses) is AttemptStart.Ok or AttemptStart.Running,
set.Attempts.FirstOrDefault(a => a.Status == LearningAttemptStatus.InProgress)?.Id,
credited.GetValueOrDefault(set.Id),
set.Attempts
.Where(a => a.Status != LearningAttemptStatus.InProgress)
.OrderByDescending(a => a.Number)
.Select(a => LearningMapper.ToResult(a, items, set))
.ToList());
})
.ToList();
return TypedResults.Ok(rows);
}
private static async Task<IResult> StartAsync(
Guid setId,
ClaimsPrincipal user,
AppDbContext db,
ISettingsProvider settingsProvider,
LearningAttemptService service,
LearningNotifier notifier,
CancellationToken ct)
{
var actor = await user.LearningActorAsync(settingsProvider);
await service.FinalizeExpiredAsync(actor.UserId, ct); // abgelaufener, nie abgegebener Versuch zählt als verbraucht
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
db.ChangeTracker.Clear();
var set = await db.LearningSets.Include(s => s.Items).Include(s => s.Attempts).AsSplitQuery().SingleOrDefaultAsync(s => s.Id == setId, ct);
if (set is null || !LearningRules.CanSolve(actor, set.TargetUserId, set.Status))
{
return TypedResults.NotFound();
}
var mine = set.Attempts.Where(a => a.UserId == actor.UserId).ToList();
var now = service.Now;
switch (LearningRules.CanStartAttempt(set.Status, set.MaxAttempts, mine.Select(a => a.Status).ToList()))
{
case AttemptStart.NoAttemptsLeft:
return EndpointHelpers.Conflict("Alle erlaubten Versuche sind aufgebraucht.");
case AttemptStart.NotAssigned:
return TypedResults.NotFound();
case AttemptStart.Running:
var running = await LoadAsync(db, mine.First(a => a.Status == LearningAttemptStatus.InProgress).Id, actor, ct);
return TypedResults.Ok(LearningMapper.ToAttemptDto(running!.Attempt, running.Set, now));
}
var created = new LearningAttempt
{
SetId = set.Id,
UserId = actor.UserId,
Number = mine.Count == 0 ? 1 : mine.Max(a => a.Number) + 1,
StartedAt = now,
DeadlineAt = LearningRules.DeadlineFor(now, set.TimeLimitMinutes),
LastActivityAt = now,
};
created.ItemOrder = LearningRules.SerializeOrder(
LearningRules.OrderItems(set.Items.OrderBy(i => i.SortOrder).Select(i => i.Id).ToList(), set.ShuffleItems, created.Id));
set.Attempts.Add(created);
LearningAttemptService.RefreshStatus(set);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// Doppelklick/zweiter Tab: der andere Start hat gewonnen (eindeutiger Index) – beim nächsten Durchlauf wird er fortgesetzt.
continue;
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.SetChanged, ct);
return TypedResults.Created($"/api/learning/my/attempts/{created.Id}", LearningMapper.ToAttemptDto(created, set, now));
}
return EndpointHelpers.Conflict("Der Versuch konnte nicht gestartet werden. Bitte erneut versuchen.");
}
private static async Task<Results<Ok<LearningAttemptDto>, NotFound>> GetAttemptAsync(
Guid attemptId, ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, LearningAttemptService service, CancellationToken ct)
{
var actor = await user.LearningActorAsync(settingsProvider);
if (await service.TryFinalizeAsync(attemptId, ct))
{
db.ChangeTracker.Clear();
}
return await LoadAsync(db, attemptId, actor, ct) is { } loaded
? TypedResults.Ok(LearningMapper.ToAttemptDto(loaded.Attempt, loaded.Set, service.Now))
: TypedResults.NotFound();
}
private static async Task<IResult> SaveAnswerAsync(
Guid attemptId,
Guid itemId,
SaveAnswerRequest request,
ClaimsPrincipal user,
AppDbContext db,
ISettingsProvider settingsProvider,
LearningAttemptService service,
CancellationToken ct)
{
var actor = await user.LearningActorAsync(settingsProvider);
var value = string.IsNullOrWhiteSpace(request.Value) ? null : request.Value.Trim();
// Zustandsbasiert: bei gleichzeitiger Änderung (z. B. Abgabe) neu laden und erneut bewerten.
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
db.ChangeTracker.Clear();
if (await LoadAsync(db, attemptId, actor, ct) is not { } loaded)
{
return TypedResults.NotFound();
}
var (entity, set) = (loaded.Attempt, loaded.Set);
var now = service.Now;
if (entity.Status != LearningAttemptStatus.InProgress)
{
return EndpointHelpers.Conflict("Der Versuch ist bereits abgegeben.");
}
if (!LearningRules.AcceptsSaves(entity.DeadlineAt, now))
{
db.ChangeTracker.Clear();
await service.TryFinalizeAsync(attemptId, ct); // Antworten bis zum Ablauf zählen; der Versuch wird automatisch abgegeben
return EndpointHelpers.Conflict("Die Zeit ist abgelaufen. Der Versuch wurde abgegeben.");
}
if (!LearningRules.ParseOrder(entity.ItemOrder).Contains(itemId) || set.Items.SingleOrDefault(i => i.Id == itemId) is not { } item)
{
return TypedResults.NotFound();
}
var answer = entity.Answers.SingleOrDefault(a => a.ItemId == itemId);
if (answer is null || answer.Value != value)
{
if (answer is null)
{
answer = new LearningAnswer { AttemptId = entity.Id, ItemId = itemId };
entity.Answers.Add(answer);
}
answer.Value = value;
answer.AnsweredAt = value is null ? null : now;
answer.Grader = LearningGrader.Auto;
if (value is null)
{
answer.Verdict = LearningVerdict.Open;
answer.AwardedPoints = 0;
answer.Reason = null;
answer.GradedAt = null;
}
else
{
var result = LearningGrading.Grade(item, value);
answer.Verdict = result.Verdict;
answer.AwardedPoints = result.AwardedPoints;
answer.Reason = result.Reason;
answer.GradedAt = now;
}
entity.LastActivityAt = now; // schützt per RowVersion vor Speichern während der Abgabe
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
continue; // Abgabe oder zweiter Tab gleichzeitig – Zustand neu lesen
}
}
var visibility = LearningRules.VisibilityFor(set.Mode, LearningAttemptStatus.InProgress, set.ShowSolutionsAfterSubmit);
return TypedResults.Ok(new SaveAnswerResultDto(
itemId,
value is not null,
visibility.ShowVerdict && value is not null ? answer.Verdict : null,
entity.DeadlineAt,
now));
}
return EndpointHelpers.Conflict("Die Antwort konnte nicht gespeichert werden. Bitte erneut versuchen.");
}
private static async Task<IResult> SubmitAsync(
Guid attemptId,
ClaimsPrincipal user,
AppDbContext db,
ISettingsProvider settingsProvider,
LearningAttemptService service,
LearningNotifier notifier,
CancellationToken ct)
{
var actor = await user.LearningActorAsync(settingsProvider);
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
db.ChangeTracker.Clear();
if (await LoadAsync(db, attemptId, actor, ct) is not { } loaded)
{
return TypedResults.NotFound();
}
var (entity, set) = (loaded.Attempt, loaded.Set);
var now = service.Now;
if (entity.Status != LearningAttemptStatus.InProgress)
{
return TypedResults.Ok(LearningMapper.ToAttemptDto(entity, set, now)); // idempotent
}
service.Submit(entity, set, now);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
continue; // gleichzeitig gespeichert/abgegeben
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.Submitted, ct);
return TypedResults.Ok(LearningMapper.ToAttemptDto(entity, set, now));
}
return EndpointHelpers.Conflict("Die Abgabe konnte nicht gespeichert werden. Bitte erneut versuchen.");
}
private sealed record LoadedAttempt(LearningAttempt Attempt, LearningSet Set);
/// <summary>Eigenen Versuch samt Blatt laden. Fremde Versuche, gelöschte Blätter und Entwürfe ergeben <c>null</c> (→ 404).</summary>
private static async Task<LoadedAttempt?> LoadAsync(AppDbContext db, Guid attemptId, LearningActor actor, CancellationToken ct)
{
var setId = await db.LearningAttempts
.Where(a => a.Id == attemptId && a.UserId == actor.UserId)
.Select(a => (Guid?)a.SetId)
.SingleOrDefaultAsync(ct);
if (setId is null)
{
return null;
}
var set = await db.LearningSets.Include(s => s.Items).Include(s => s.Attempts).ThenInclude(a => a.Answers).AsSplitQuery()
.SingleOrDefaultAsync(s => s.Id == setId, ct);
var attempt = set?.Attempts.SingleOrDefault(a => a.Id == attemptId);
return set is null || attempt is null || !LearningRules.CanSolve(actor, set.TargetUserId, set.Status) ? null : new LoadedAttempt(attempt, set);
}
}
@@ -0,0 +1,220 @@
using System.Security.Claims;
using FamilyPlanner.Api.Hubs;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Infrastructure.Identity;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared.Learning;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Learning;
internal static class LearningAccess
{
/// <summary>Der Benutzer mit seinen Lern-Rechten für objektbezogene Prüfungen (ADR-0021).</summary>
public static async Task<LearningActor> LearningActorAsync(this ClaimsPrincipal user, ISettingsProvider settingsProvider)
{
var settings = await settingsProvider.GetAsync();
return LearningActor.From(user.UserId() ?? Guid.Empty, user.FindAll(ClaimTypes.Role).Select(c => c.Value), settings.ToPermissionOptions());
}
public static async Task<Dictionary<Guid, string>> DisplayNamesAsync(AppDbContext db, IEnumerable<Guid> ids, CancellationToken ct)
{
var distinct = ids.Distinct().ToList();
var users = await db.Users.IgnoreQueryFilters().AsNoTracking()
.Where(u => distinct.Contains(u.Id))
.Select(u => new { u.Id, u.DisplayName, u.UserName })
.ToListAsync(ct);
return users.ToDictionary(u => u.Id, u => string.IsNullOrWhiteSpace(u.DisplayName) ? u.UserName ?? string.Empty : u.DisplayName);
}
}
/// <summary>
/// Sendet Lern-Ereignisse – <b>ohne Inhalte</b> – nur an die Zielperson und alle Eltern (Gruppe <c>user:{id}</c>, wie TaskNotifier/ChatNotifier).
/// Die Clients laden über die geschützten Endpunkte nach; andere Kinder und Gäste erfahren nichts.
/// </summary>
public sealed class LearningNotifier(AppDbContext db, IHubContext<LiveHub> hub, ISettingsProvider settingsProvider, TimeProvider timeProvider)
{
public async Task ChangedAsync(Guid setId, Guid targetUserId, LearningChangeKind kind, CancellationToken ct, Guid? previousTargetUserId = null)
{
var groups = (await ParentIdsAsync(ct)).Append(targetUserId);
if (previousTargetUserId is { } previous)
{
groups = groups.Append(previous);
}
var recipients = groups.Distinct().Select(id => LiveHub.UserGroup(id.ToString())).ToList();
await hub.Clients.Groups(recipients).SendAsync(LearningEvents.Changed, new LearningChangedDto(setId, kind), ct);
}
/// <summary>Aktive Benutzer mit Recht <c>LearningManage</c>.</summary>
private async Task<List<Guid>> ParentIdsAsync(CancellationToken ct)
{
var options = (await settingsProvider.GetAsync(ct)).ToPermissionOptions();
var roles = await db.GetRoleMapAsync(ct);
var now = timeProvider.GetUtcNow();
var ids = await db.Users.AsNoTracking()
.Where(u => u.IsActive && (u.ExpiresAt == null || u.ExpiresAt > now))
.Select(u => u.Id)
.ToListAsync(ct);
return ids
.Where(id => roles.TryGetValue(id, out var role) && PermissionMatrix.For(Permission.LearningManage, role, options) == AccessLevel.Full)
.ToList();
}
}
/// <summary>Wandelt Entitäten in DTOs. Kinder-DTOs entstehen ausschließlich hier über <see cref="LearningRules.VisibilityFor"/>.</summary>
internal static class LearningMapper
{
public static LearningSetDto ToDto(LearningSet s, int itemCount, int attemptCount, int pendingReview, int pointsCredited) => new(
s.Id, s.Title, s.Subject, s.GradeLevel, s.TargetUserId, s.DueDate, s.Status, s.Mode, s.MaxAttempts, s.TimeLimitMinutes,
s.ShowSolutionsAfterSubmit, s.ShuffleItems, s.TotalPoints, s.GradeThresholds, s.Source, s.AiPrompt, s.AiProvider, s.AiModel,
s.AssignedAt, itemCount, attemptCount, pendingReview, pointsCredited, s.CreatedAt, s.UpdatedAt, s.RowVersion);
/// <summary>Für geladene Blätter mit Aufgaben und Versuchen.</summary>
public static LearningSetDto ToDto(LearningSet s, int pointsCredited) => ToDto(
s, s.Items.Count, s.Attempts.Count, s.Attempts.Count(a => a.Status == LearningAttemptStatus.Submitted), pointsCredited);
public static LearningItemDto ToDto(LearningItem i) => new(
i.Id, i.SortOrder, i.Question, i.AnswerType, i.CorrectAnswer, i.NumberTolerance, [.. i.Options], i.Hint, i.Explanation,
i.Difficulty, i.Points, i.RowVersion);
public static LearningSetDetailDto ToDetail(LearningSet s, int pointsCredited) => new(
ToDto(s, pointsCredited),
s.Items.OrderBy(i => i.SortOrder).Select(ToDto).ToList(),
s.Attempts.OrderBy(a => a.Number).Select(a => new LearningAttemptSummaryDto(
a.Id, a.Number, a.Status, a.StartedAt, a.SubmittedAt, a.AutoSubmitted, a.EarnedPoints, a.TotalItemPoints, a.Grade)).ToList());
public static LearningScore ScoreOf(LearningAttempt attempt, IReadOnlyDictionary<Guid, LearningItem> items) =>
LearningRules.Score(attempt.Answers
.Where(a => items.ContainsKey(a.ItemId))
.Select(a => new ScoredAnswer(items[a.ItemId].Points, a.Verdict, a.AwardedPoints)));
/// <summary>Gesamtpunkte der Aufgaben, die zum Versuch gehören (auch wenn noch nicht beantwortet).</summary>
public static int TotalPointsOf(LearningAttempt attempt, IReadOnlyDictionary<Guid, LearningItem> items) =>
LearningRules.ParseOrder(attempt.ItemOrder).Where(items.ContainsKey).Sum(id => items[id].Points);
public static MyLearningResultDto ToResult(LearningAttempt attempt, IReadOnlyDictionary<Guid, LearningItem> items, LearningSet set)
{
var score = ScoreOf(attempt, items);
var final = attempt.Status == LearningAttemptStatus.Reviewed;
var total = TotalPointsOf(attempt, items);
return new MyLearningResultDto(
attempt.Id,
attempt.Number,
attempt.Status,
attempt.SubmittedAt,
attempt.AutoSubmitted,
final ? attempt.EarnedPoints ?? score.Earned : score.Earned,
final ? attempt.TotalItemPoints ?? total : total,
score.OpenCount,
final && set.Mode == LearningMode.Exam ? attempt.Grade : null,
final);
}
/// <summary>Versuch aus Kindersicht: Lösungen, Hinweise und Bewertung nur, wenn die Regeln es für Modus und Status erlauben.</summary>
public static LearningAttemptDto ToAttemptDto(LearningAttempt attempt, LearningSet set, DateTimeOffset now)
{
var visibility = LearningRules.VisibilityFor(set.Mode, attempt.Status, set.ShowSolutionsAfterSubmit);
var items = set.Items.ToDictionary(i => i.Id);
var answers = attempt.Answers.ToDictionary(a => a.ItemId);
var rows = new List<LearningAttemptItemDto>();
var position = 0;
foreach (var id in LearningRules.ParseOrder(attempt.ItemOrder))
{
if (!items.TryGetValue(id, out var item))
{
continue;
}
answers.TryGetValue(id, out var answer);
LearningItemFeedbackDto? feedback = null;
var answered = answer?.AnsweredAt is not null;
if (visibility.ShowVerdict && answer is not null && (attempt.Status != LearningAttemptStatus.InProgress || answered))
{
feedback = new LearningItemFeedbackDto(
answer.Verdict,
answer.AwardedPoints,
attempt.Status == LearningAttemptStatus.InProgress ? null : answer.Reason,
visibility.ShowSolution ? item.CorrectAnswer : null,
visibility.ShowSolution ? item.Explanation : null);
}
else if (visibility.ShowSolution && attempt.Status != LearningAttemptStatus.InProgress)
{
feedback = new LearningItemFeedbackDto(LearningVerdict.Open, 0, null, item.CorrectAnswer, item.Explanation);
}
rows.Add(new LearningAttemptItemDto(
id,
position++,
item.Question,
item.AnswerType,
[.. item.Options],
item.Points,
item.Difficulty,
answer?.Value,
visibility.ShowHint ? item.Hint : null,
feedback));
}
return new LearningAttemptDto(
attempt.Id,
set.Id,
set.Title,
set.Subject,
set.Mode,
attempt.Number,
attempt.Status,
attempt.StartedAt,
attempt.DeadlineAt,
now,
attempt.AutoSubmitted,
rows,
attempt.Status == LearningAttemptStatus.InProgress ? null : ToResult(attempt, items, set),
attempt.RowVersion);
}
/// <summary>Versuch aus Elternsicht (Frage, Musterlösung, Kinderantwort, Bewertung).</summary>
public static LearningAttemptReviewDto ToReviewDto(LearningAttempt attempt, LearningSet set, string childName, int pointsCredited)
{
var items = set.Items.ToDictionary(i => i.Id);
var answers = attempt.Answers.ToDictionary(a => a.ItemId);
var rows = new List<LearningReviewAnswerDto>();
var position = 0;
foreach (var id in LearningRules.ParseOrder(attempt.ItemOrder))
{
if (!items.TryGetValue(id, out var item))
{
continue;
}
answers.TryGetValue(id, out var answer);
rows.Add(new LearningReviewAnswerDto(
id, position++, item.Question, item.AnswerType, [.. item.Options], item.CorrectAnswer, item.Explanation, item.Points,
answer?.Value, answer?.Verdict ?? LearningVerdict.Open, answer?.Grader ?? LearningGrader.Auto, answer?.AwardedPoints ?? 0, answer?.Reason));
}
var score = ScoreOf(attempt, items);
var total = TotalPointsOf(attempt, items);
int? grade = set.Mode == LearningMode.Exam
? attempt.Status == LearningAttemptStatus.Reviewed
? attempt.Grade
: score.OpenCount == 0 ? LearningGrades.GradeFor(score.Earned, total, Thresholds(set)) : null
: null;
return new LearningAttemptReviewDto(
attempt.Id, set.Id, set.Title, set.Subject, set.Mode, set.TotalPoints, attempt.UserId, childName, attempt.Number, attempt.Status,
attempt.StartedAt, attempt.SubmittedAt, attempt.AutoSubmitted, rows, score.Earned, total, score.OpenCount, grade, pointsCredited,
attempt.ReviewNote, attempt.RowVersion);
}
public static IReadOnlyList<int> Thresholds(LearningSet set)
{
LearningGrades.TryParse(set.GradeThresholds, out var thresholds, out _);
return thresholds;
}
}
+5
View File
@@ -4,6 +4,7 @@ using FamilyPlanner.Api.Features.Auth;
using FamilyPlanner.Api.Features.Calendar;
using FamilyPlanner.Api.Features.Chat;
using FamilyPlanner.Api.Features.Dashboard;
using FamilyPlanner.Api.Features.Learning;
using FamilyPlanner.Api.Features.Meals;
using FamilyPlanner.Api.Features.Push;
using FamilyPlanner.Api.Features.Shopping;
@@ -46,6 +47,9 @@ builder.Services.AddScoped<IAiProvider, OllamaProvider>();
builder.Services.AddScoped<IAiProvider, ClaudeProvider>();
builder.Services.AddScoped<IAiProvider, OpenAiCompatibleProvider>();
builder.Services.AddScoped<ChatNotifier>();
builder.Services.AddScoped<LearningNotifier>();
builder.Services.AddScoped<LearningAttemptService>();
builder.Services.AddHostedService<LearningExpiryService>();
builder.Services.AddSingleton<ChatTypingThrottle>();
// Web-Push (ADR-0014): ohne VAPID-Schlüssel bleibt der Versand inaktiv.
@@ -88,6 +92,7 @@ app.MapFeedEndpoints();
app.MapDashboardEndpoints();
app.MapChatEndpoints();
app.MapAiEndpoints();
app.MapLearningEndpoints();
app.MapHub<LiveHub>("/hubs/live");
app.Run();
@@ -29,6 +29,22 @@ public sealed record DashboardFamilyTasksDto(int OpenCount, int OverdueCount, in
public sealed record DashboardChatDto(int UnreadCount);
/// <summary>Eintrag der Lern-Kachel: für Kinder ein offenes Blatt, für Eltern ein Versuch, der geprüft werden muss (<paramref name="AttemptId"/>).</summary>
public sealed record DashboardLearningItemDto(
Guid SetId,
Guid? AttemptId,
string Title,
string Subject,
DateOnly? DueDate,
bool IsOverdue,
string? ChildName);
/// <summary>
/// Lern-Kachel (ADR-0021). Kinder: offene Blätter. Eltern: <see cref="ToReviewCount"/> abgegebene Versuche zur Prüfung und
/// <see cref="OpenCount"/> noch offene Blätter der Kinder.
/// </summary>
public sealed record DashboardLearningDto(int OpenCount, int ToReviewCount, bool ForParents, IReadOnlyList<DashboardLearningItemDto> Items);
/// <summary>
/// Alle Kacheln der Startseite in einem Request. Eine Kachel ist <c>null</c>, wenn die Rolle sie nicht sehen darf
/// (z. B. Gäste ohne Chat, Kinder ohne Familienaufgaben). Die Berechtigungen werden serverseitig geprüft.
@@ -40,4 +56,5 @@ public sealed record DashboardDto(
DashboardShoppingDto? Shopping,
DashboardMyTasksDto? MyTasks,
DashboardFamilyTasksDto? FamilyTasks,
DashboardChatDto? Chat);
DashboardChatDto? Chat,
DashboardLearningDto? Learning = null);