feat: Kompetenzzuordnungen von Freitext-Code auf stabile CompetencyItem.Id umgestellt (TODO 16.5)
Unit/Lesson/ExamTask/ParticipationSession referenzierten Kompetenzen bisher per Freitext-Code, case-insensitiv gegen den Katalog gematcht - fragil bei Umbenennungen und seit der Bildungsplan-Versionierung nicht mehr eindeutig (zwei Versionen können denselben Code vergeben). Neuer Werttyp CompetencyReference (ItemId + Code/Description-Momentaufnahme) löst das: solange die Id noch im Katalog auflösbar ist, zählt die live Fassung (Rename wirkt sofort), sonst bleibt die gespeicherte historische Fassung sichtbar statt spurlos zu verschwinden. Migration (Schema-Version 6) löst bestehende Codes gegen den Katalog auf; die zuvor dreifach duplizierte Kompetenz-Tag-UI ist jetzt in CompetencyTagGroupBuilder konsolidiert. add_lesson_competency/remove_lesson_competency nehmen jetzt itemId statt code und validieren gegen den Katalog (vorher ungeprüft). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f97edff2fe
commit
dc2f58c4dc
28 files changed
+691
-202
No files matched your search
@@ -289,6 +289,9 @@ public interface ICompetencyDomainRepository
|
||||
// gesamten Katalog einer Version (z.B. MCP-Tool get_competency_catalog ohne gradeLevel-Filter),
|
||||
// ohne dass der Aufrufer erst jede vorkommende Klassenstufe einzeln erraten/abfragen müsste.
|
||||
List<CompetencyDomain> GetBySubjectAndVersion(Guid subjectId, Guid versionId);
|
||||
// Über alle Fächer/Klassenstufen/Versionen hinweg - für Aufrufer, die eine CompetencyItem.Id
|
||||
// auflösen müssen, ohne vorher das Fach der Zuordnung zu kennen (z.B. MCP add_lesson_competency).
|
||||
List<CompetencyDomain> GetAll();
|
||||
CompetencyDomain? GetById(Guid id);
|
||||
void Save(CompetencyDomain domain);
|
||||
void Delete(Guid id);
|
||||
|
||||
@@ -40,3 +40,17 @@ public class CompetencyItem
|
||||
public string Description { get; set; } = "";
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Referenz auf eine Kompetenz aus dem Katalog, momentaufnahmeartig gespeichert (analog zu
|
||||
/// Exam.GradingKey/Exam.Tasks, siehe Datenmodell.md "Bewusst gespeicherte Momentaufnahmen"):
|
||||
/// Code/Description werden zum Zuordnungszeitpunkt kopiert. Solange ItemId noch im Katalog
|
||||
/// auflösbar ist, zeigt die UI die live aufgelöste Fassung (ein Rename wirkt sich sofort überall
|
||||
/// aus); wird das Item gelöscht, bleibt die hier gespeicherte historische Fassung lesbar statt
|
||||
/// spurlos zu verschwinden. ItemId == Guid.Empty markiert eine nie aufgelöste Referenz (z.B. aus
|
||||
/// einem plattformübergreifenden Import) - Code/Description sind dann die einzige Information.</summary>
|
||||
public class CompetencyReference
|
||||
{
|
||||
public Guid ItemId { get; set; }
|
||||
public string Code { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
}
|
||||
@@ -28,7 +28,7 @@ public class ExamTask
|
||||
public string? Title { get; set; }
|
||||
public double MaxPoints { get; set; }
|
||||
public double Weight { get; set; } = 1.0;
|
||||
public List<string> CompetencyCodes { get; set; } = [];
|
||||
public List<CompetencyReference> Competencies { get; set; } = [];
|
||||
}
|
||||
public class GradingKeyEntry
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ public class ParticipationSession
|
||||
public DateOnly Date { get; set; } = DateOnly.FromDateTime(DateTime.Today);
|
||||
public Guid? LessonId { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
public List<string> CompetencyCodes { get; set; } = [];
|
||||
public List<CompetencyReference> Competencies { get; set; } = [];
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -101,8 +101,8 @@ public class ParticipationSection
|
||||
|
||||
public class CompetencyRating
|
||||
{
|
||||
public string Code { get; set; } = "";
|
||||
public int Value { get; set; }
|
||||
public Guid ItemId { get; set; }
|
||||
public int Value { get; set; }
|
||||
}
|
||||
|
||||
public class AspectRating
|
||||
|
||||
@@ -37,7 +37,7 @@ public class Unit
|
||||
public string Title { get; set; } = "";
|
||||
public DateOnly? StartDate { get; set; }
|
||||
public DateOnly? EndDate { get; set; }
|
||||
public List<string> Competencies { get; set; } = [];
|
||||
public List<CompetencyReference> Competencies { get; set; } = [];
|
||||
public UnitStatus Status { get; set; } = UnitStatus.Planned;
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
@@ -71,11 +71,11 @@ public class Lesson : IHasAttachments
|
||||
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
|
||||
public bool HomeworkCheckDismissed { get; set; }
|
||||
public string? Reflection { get; set; }
|
||||
/// Kompetenzcodes (<see cref="CompetencyItem.Code"/>) aus dem Katalog, die dieser Stunde als
|
||||
/// Ganzes zugeordnet sind — analog zu <see cref="Unit.Competencies"/>, aber auf Stundenebene statt
|
||||
/// Einheitenebene. Bewusst noch keine Verknüpfung je einzelner <see cref="LessonPhaseStep"/>
|
||||
/// (siehe TODO.md 4.5.8, weiterhin offen); diese Liste deckt nur die gröbere Zuordnung ab.
|
||||
public List<string> Competencies { get; set; } = [];
|
||||
/// Kompetenzen aus dem Katalog, die dieser Stunde als Ganzes zugeordnet sind — analog zu
|
||||
/// <see cref="Unit.Competencies"/>, aber auf Stundenebene statt Einheitenebene. Bewusst noch
|
||||
/// keine Verknüpfung je einzelner <see cref="LessonPhaseStep"/> (siehe TODO.md 4.5.8, weiterhin
|
||||
/// offen); diese Liste deckt nur die gröbere Zuordnung ab.
|
||||
public List<CompetencyReference> Competencies { get; set; } = [];
|
||||
public LessonStatus Status { get; set; } = LessonStatus.Planned;
|
||||
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
|
||||
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
|
||||
|
||||
@@ -32,31 +32,54 @@ public sealed class CompetencyAnalysisService
|
||||
IReadOnlyList<ExamResult> results)
|
||||
{
|
||||
var builders = new List<ItemBuilder>();
|
||||
var byCode = new Dictionary<string, ItemBuilder>(StringComparer.OrdinalIgnoreCase);
|
||||
var byId = new Dictionary<Guid, ItemBuilder>();
|
||||
// Referenzen, die nie einem Katalogeintrag zugeordnet werden konnten (ItemId == Guid.Empty,
|
||||
// z.B. aus einem plattformübergreifenden Import) haben keine gemeinsame Id, über die
|
||||
// mehrfache Zuordnungen derselben Kompetenz zusammengeführt werden könnten - dafür wird
|
||||
// ersatzweise der Code-Text als Merge-Schlüssel verwendet.
|
||||
var byUnresolvedCode = new Dictionary<string, ItemBuilder>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var domain in catalog.OrderBy(x => x.SortOrder))
|
||||
foreach (var item in domain.Items.OrderBy(x => x.SortOrder))
|
||||
{
|
||||
var builder = new ItemBuilder(domain.Name, domain.Code, item.Code, item.Description);
|
||||
builders.Add(builder);
|
||||
if (!string.IsNullOrWhiteSpace(item.Code)) byCode.TryAdd(item.Code.Trim(), builder);
|
||||
byId.TryAdd(item.Id, builder);
|
||||
}
|
||||
|
||||
ItemBuilder GetOrAdd(string rawCode)
|
||||
// Solange die Referenz noch im aktuellen Katalog auflösbar ist, zählt die LIVE-Fassung
|
||||
// (ein Rename wirkt sich damit sofort aus). Ist sie es nicht mehr (gelöscht, andere
|
||||
// Bildungsplan-Version, oder nie aufgelöster plattformübergreifender Import), wird ein
|
||||
// Platzhalter aus der zum Zuordnungszeitpunkt gespeicherten Momentaufnahme synthetisiert -
|
||||
// dadurch bleibt (anders als bei der bisherigen reinen Code-Textkonserve) sogar die echte
|
||||
// historische Beschreibung erhalten.
|
||||
ItemBuilder GetOrAdd(CompetencyReference reference)
|
||||
{
|
||||
var code = rawCode.Trim();
|
||||
if (byCode.TryGetValue(code, out var existing)) return existing;
|
||||
var added = new ItemBuilder("Nicht im Katalog", "", code,
|
||||
"Dieser Kompetenzcode wird noch verwendet, ist aber nicht im aktuellen Katalog enthalten.");
|
||||
byCode[code] = added;
|
||||
var description = string.IsNullOrWhiteSpace(reference.Description)
|
||||
? "Diese Kompetenz wird noch verwendet, ist aber nicht (mehr) im aktuellen Katalog enthalten."
|
||||
: reference.Description;
|
||||
|
||||
if (reference.ItemId == Guid.Empty)
|
||||
{
|
||||
var codeKey = reference.Code.Trim();
|
||||
if (byUnresolvedCode.TryGetValue(codeKey, out var existingUnresolved)) return existingUnresolved;
|
||||
var addedUnresolved = new ItemBuilder("Nicht im Katalog", "", reference.Code, description);
|
||||
byUnresolvedCode[codeKey] = addedUnresolved;
|
||||
builders.Add(addedUnresolved);
|
||||
return addedUnresolved;
|
||||
}
|
||||
|
||||
if (byId.TryGetValue(reference.ItemId, out var existing)) return existing;
|
||||
var added = new ItemBuilder("Nicht im Katalog", "", reference.Code, description);
|
||||
byId[reference.ItemId] = added;
|
||||
builders.Add(added);
|
||||
return added;
|
||||
}
|
||||
|
||||
foreach (var unit in units)
|
||||
foreach (var code in NormalizeCodes(unit.Competencies))
|
||||
foreach (var reference in NormalizeReferences(unit.Competencies))
|
||||
{
|
||||
var builder = GetOrAdd(code);
|
||||
var builder = GetOrAdd(reference);
|
||||
if (unit.Status == UnitStatus.Planned) builder.PlannedUnits++;
|
||||
else builder.TaughtUnits++;
|
||||
}
|
||||
@@ -64,10 +87,10 @@ public sealed class CompetencyAnalysisService
|
||||
var resultsByExam = results.GroupBy(x => x.ExamId).ToDictionary(x => x.Key, x => x.ToList());
|
||||
foreach (var exam in exams)
|
||||
{
|
||||
var examCodes = NormalizeCodes(exam.Tasks.SelectMany(x => x.CompetencyCodes));
|
||||
foreach (var code in examCodes)
|
||||
var examReferences = NormalizeReferences(exam.Tasks.SelectMany(x => x.Competencies));
|
||||
foreach (var reference in examReferences)
|
||||
{
|
||||
var builder = GetOrAdd(code);
|
||||
var builder = GetOrAdd(reference);
|
||||
if (exam.Status == ExamStatus.Planned) builder.PlannedExams++;
|
||||
else builder.AssessedExams++;
|
||||
}
|
||||
@@ -81,17 +104,16 @@ public sealed class CompetencyAnalysisService
|
||||
var task = exam.Tasks[taskIndex];
|
||||
if (task.MaxPoints <= 0) continue;
|
||||
var achieved = Math.Clamp(result.Points[taskIndex], 0, task.MaxPoints);
|
||||
foreach (var code in NormalizeCodes(task.CompetencyCodes))
|
||||
GetOrAdd(code).AddScore(result.StudentId, achieved, task.MaxPoints);
|
||||
foreach (var reference in NormalizeReferences(task.Competencies))
|
||||
GetOrAdd(reference).AddScore(result.StudentId, achieved, task.MaxPoints);
|
||||
}
|
||||
}
|
||||
|
||||
return builders.Select(x => x.Build()).ToList();
|
||||
}
|
||||
|
||||
private static bool HasCode(string? value) => !string.IsNullOrWhiteSpace(value);
|
||||
private static IEnumerable<string> NormalizeCodes(IEnumerable<string> codes) =>
|
||||
codes.Where(HasCode).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
private static IEnumerable<CompetencyReference> NormalizeReferences(IEnumerable<CompetencyReference> references) =>
|
||||
references.DistinctBy(x => x.ItemId);
|
||||
|
||||
private sealed class ItemBuilder(
|
||||
string domainName, string domainCode, string code, string description)
|
||||
|
||||
@@ -121,7 +121,7 @@ public sealed class LiteDbContextTests
|
||||
using var temp = new TempDatabase();
|
||||
using var context = new LiteDbContext(temp.Path);
|
||||
|
||||
Assert.Equal(5, context.SchemaVersion);
|
||||
Assert.Equal(6, context.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,7 +132,7 @@ public sealed class LiteDbContextTests
|
||||
|
||||
using var second = new LiteDbContext(temp.Path);
|
||||
|
||||
Assert.Equal(5, second.SchemaVersion);
|
||||
Assert.Equal(6, second.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -180,6 +180,93 @@ public sealed class LiteDbContextTests
|
||||
Assert.Equal(versionB.Id, domainsAfter[domainBId].VersionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MigrateCompetencyReferences_LoestCodesGegenKatalogAufUndBehaeltUnbekannteAlsText()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
var unitId = Guid.NewGuid();
|
||||
var examId = Guid.NewGuid();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var entryId = Guid.NewGuid();
|
||||
var itemId = Guid.NewGuid();
|
||||
|
||||
using (var legacy = new LiteDatabase(temp.Path))
|
||||
{
|
||||
legacy.GetCollection<BsonDocument>("competency_domains").Insert(new BsonDocument
|
||||
{
|
||||
["_id"] = Guid.NewGuid(),
|
||||
[nameof(CompetencyDomain.Name)] = "Bereich",
|
||||
[nameof(CompetencyDomain.Items)] = new BsonArray
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
["_id"] = itemId,
|
||||
[nameof(CompetencyItem.Code)] = "K1",
|
||||
[nameof(CompetencyItem.Description)] = "Erste Kompetenz",
|
||||
},
|
||||
},
|
||||
});
|
||||
legacy.GetCollection<BsonDocument>("units").Insert(new BsonDocument
|
||||
{
|
||||
["_id"] = unitId,
|
||||
[nameof(Unit.GroupId)] = Guid.NewGuid(),
|
||||
[nameof(Unit.Competencies)] = new BsonArray { "K1", "ALT" },
|
||||
});
|
||||
legacy.GetCollection<BsonDocument>("exams").Insert(new BsonDocument
|
||||
{
|
||||
["_id"] = examId,
|
||||
[nameof(Exam.GroupId)] = Guid.NewGuid(),
|
||||
[nameof(Exam.Tasks)] = new BsonArray
|
||||
{
|
||||
new BsonDocument { ["Nr"] = 1, ["MaxPoints"] = 10.0, ["CompetencyCodes"] = new BsonArray { "K1" } },
|
||||
},
|
||||
});
|
||||
legacy.GetCollection<BsonDocument>("participation_sessions").Insert(new BsonDocument
|
||||
{
|
||||
["_id"] = sessionId,
|
||||
[nameof(ParticipationSession.GroupId)] = Guid.NewGuid(),
|
||||
["CompetencyCodes"] = new BsonArray { "K1" },
|
||||
});
|
||||
legacy.GetCollection<BsonDocument>("participation").Insert(new BsonDocument
|
||||
{
|
||||
["_id"] = entryId,
|
||||
[nameof(ParticipationEntry.SessionId)] = sessionId,
|
||||
[nameof(ParticipationEntry.StudentId)] = Guid.NewGuid(),
|
||||
[nameof(ParticipationEntry.CompetencyRatings)] = new BsonArray
|
||||
{
|
||||
new BsonDocument { ["Code"] = "K1", ["Value"] = 2 },
|
||||
new BsonDocument { ["Code"] = "UNBEKANNT", ["Value"] = 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
using var context = new LiteDbContext(temp.Path);
|
||||
|
||||
var unit = context.Units.FindById(unitId);
|
||||
Assert.Equal(2, unit!.Competencies.Count);
|
||||
var known = unit.Competencies.Single(c => c.Code == "K1");
|
||||
Assert.Equal(itemId, known.ItemId);
|
||||
Assert.Equal("Erste Kompetenz", known.Description);
|
||||
var unknown = unit.Competencies.Single(c => c.Code == "ALT");
|
||||
Assert.Equal(Guid.Empty, unknown.ItemId);
|
||||
|
||||
var exam = context.Exams.FindById(examId);
|
||||
var task = Assert.Single(exam!.Tasks);
|
||||
var examRef = Assert.Single(task.Competencies);
|
||||
Assert.Equal(itemId, examRef.ItemId);
|
||||
|
||||
var session = context.ParticipationSessions.FindById(sessionId);
|
||||
var sessionRef = Assert.Single(session!.Competencies);
|
||||
Assert.Equal(itemId, sessionRef.ItemId);
|
||||
|
||||
// Rating mit unbekanntem Code wird verworfen (kein sinnvoll erhaltbarer Zahlenwert ohne
|
||||
// erkennbare Identität) - nur das auflösbare Rating bleibt übrig.
|
||||
var entry = context.ParticipationEntries.FindById(entryId);
|
||||
var rating = Assert.Single(entry!.CompetencyRatings);
|
||||
Assert.Equal(itemId, rating.ItemId);
|
||||
Assert.Equal(2, rating.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MigrateLessonPhases_FasstAltePhaseMethodenUndMaterialienVerlustfreiZusammen()
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ public class LiteDbContext : IDisposable
|
||||
/// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter
|
||||
/// <see cref="RunVersionedMigrations"/> ergänzt, statt bei jedem Start erneut
|
||||
/// (idempotent, aber unnötig) über alle Daten zu laufen.
|
||||
private const int CurrentSchemaVersion = 5;
|
||||
private const int CurrentSchemaVersion = 6;
|
||||
|
||||
private readonly LiteDatabase _db;
|
||||
|
||||
@@ -268,6 +268,11 @@ public class LiteDbContext : IDisposable
|
||||
MigrateCompetencyCatalogVersions();
|
||||
version = 5;
|
||||
}
|
||||
if (version < 6)
|
||||
{
|
||||
MigrateCompetencyReferences();
|
||||
version = 6;
|
||||
}
|
||||
WriteSchemaVersion(version);
|
||||
}
|
||||
|
||||
@@ -516,6 +521,114 @@ public class LiteDbContext : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>TODO 16.5: stellt Unit/Lesson/ExamTask/ParticipationSession-Kompetenzzuordnungen von
|
||||
/// Freitext-Codes auf stabile CompetencyItem.Id-Referenzen um (siehe CompetencyReference).
|
||||
/// Jeder alte Code wird gegen den Katalog aufgelöst (erster Treffer über alle Fächer/Versionen
|
||||
/// hinweg, case-insensitiv) und als Momentaufnahme {ItemId, Code, Description} gespeichert;
|
||||
/// ohne Treffer bleibt der Text als ItemId=Guid.Empty erhalten statt verloren zu gehen. Bei
|
||||
/// ExamTask/ParticipationSession wird das Feld zugleich von "CompetencyCodes" auf "Competencies"
|
||||
/// umbenannt (das alte typisierte Feld existiert nicht mehr). ParticipationEntry.CompetencyRatings
|
||||
/// ohne auflösbaren Code werden verworfen - ein Zahlenwert ohne erkennbare Identität ist nicht
|
||||
/// sinnvoll erhaltbar.</summary>
|
||||
private void MigrateCompetencyReferences()
|
||||
{
|
||||
var byCode = new Dictionary<string, CompetencyItem>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var domain in CompetencyDomains.FindAll())
|
||||
foreach (var item in domain.Items)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Code)) continue;
|
||||
byCode.TryAdd(item.Code.Trim(), item);
|
||||
}
|
||||
|
||||
BsonArray ConvertCodesToReferences(BsonValue oldValue)
|
||||
{
|
||||
var result = new BsonArray();
|
||||
if (!oldValue.IsArray) return result;
|
||||
foreach (var entry in oldValue.AsArray)
|
||||
{
|
||||
if (!entry.IsString) continue;
|
||||
var code = entry.AsString.Trim();
|
||||
if (code.Length == 0) continue;
|
||||
result.Add(byCode.TryGetValue(code, out var item)
|
||||
? new BsonDocument
|
||||
{
|
||||
[nameof(CompetencyReference.ItemId)] = item.Id,
|
||||
[nameof(CompetencyReference.Code)] = item.Code,
|
||||
[nameof(CompetencyReference.Description)] = item.Description,
|
||||
}
|
||||
: new BsonDocument
|
||||
{
|
||||
[nameof(CompetencyReference.ItemId)] = Guid.Empty,
|
||||
[nameof(CompetencyReference.Code)] = code,
|
||||
[nameof(CompetencyReference.Description)] = "",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
var units = _db.GetCollection<BsonDocument>("units");
|
||||
foreach (var doc in units.FindAll().ToList())
|
||||
{
|
||||
if (!doc.TryGetValue(nameof(Unit.Competencies), out var old)) continue;
|
||||
doc[nameof(Unit.Competencies)] = ConvertCodesToReferences(old);
|
||||
units.Update(doc);
|
||||
}
|
||||
|
||||
var lessonDocs = _db.GetCollection<BsonDocument>("lessons");
|
||||
foreach (var doc in lessonDocs.FindAll().ToList())
|
||||
{
|
||||
if (!doc.TryGetValue(nameof(Lesson.Competencies), out var old)) continue;
|
||||
doc[nameof(Lesson.Competencies)] = ConvertCodesToReferences(old);
|
||||
lessonDocs.Update(doc);
|
||||
}
|
||||
|
||||
var exams = _db.GetCollection<BsonDocument>("exams");
|
||||
foreach (var doc in exams.FindAll().ToList())
|
||||
{
|
||||
if (!doc.TryGetValue(nameof(Exam.Tasks), out var tasksValue) || !tasksValue.IsArray) continue;
|
||||
var changed = false;
|
||||
foreach (var taskValue in tasksValue.AsArray)
|
||||
{
|
||||
if (taskValue is not BsonDocument task) continue;
|
||||
if (!task.TryGetValue("CompetencyCodes", out var old)) continue;
|
||||
task.Remove("CompetencyCodes");
|
||||
task[nameof(ExamTask.Competencies)] = ConvertCodesToReferences(old);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) exams.Update(doc);
|
||||
}
|
||||
|
||||
var sessions = _db.GetCollection<BsonDocument>("participation_sessions");
|
||||
foreach (var doc in sessions.FindAll().ToList())
|
||||
{
|
||||
if (!doc.TryGetValue("CompetencyCodes", out var old)) continue;
|
||||
doc.Remove("CompetencyCodes");
|
||||
doc[nameof(ParticipationSession.Competencies)] = ConvertCodesToReferences(old);
|
||||
sessions.Update(doc);
|
||||
}
|
||||
|
||||
var entries = _db.GetCollection<BsonDocument>("participation");
|
||||
foreach (var doc in entries.FindAll().ToList())
|
||||
{
|
||||
if (!doc.TryGetValue(nameof(ParticipationEntry.CompetencyRatings), out var ratingsValue) || !ratingsValue.IsArray) continue;
|
||||
var newRatings = new BsonArray();
|
||||
foreach (var ratingValue in ratingsValue.AsArray)
|
||||
{
|
||||
if (ratingValue is not BsonDocument rating) continue;
|
||||
if (!rating.TryGetValue("Code", out var codeVal) || !codeVal.IsString) continue;
|
||||
if (!byCode.TryGetValue(codeVal.AsString.Trim(), out var item)) continue;
|
||||
var value = rating.TryGetValue(nameof(CompetencyRating.Value), out var v) && v.IsNumber ? v.AsInt32 : 0;
|
||||
newRatings.Add(new BsonDocument
|
||||
{
|
||||
[nameof(CompetencyRating.ItemId)] = item.Id,
|
||||
[nameof(CompetencyRating.Value)] = value,
|
||||
});
|
||||
}
|
||||
doc[nameof(ParticipationEntry.CompetencyRatings)] = newRatings;
|
||||
entries.Update(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureIndexes()
|
||||
{
|
||||
Students.EnsureIndex(x => x.LastName);
|
||||
|
||||
@@ -927,6 +927,7 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
||||
.Find(d => d.SubjectId == subjectId && d.VersionId == versionId)
|
||||
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder)
|
||||
.ToList();
|
||||
public List<CompetencyDomain> GetAll() => db.CompetencyDomains.FindAll().ToList();
|
||||
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
||||
public void Save(CompetencyDomain d)
|
||||
{
|
||||
|
||||
@@ -13,16 +13,18 @@ public sealed class CompetencyOverviewViewModelTests
|
||||
var subjectId = Guid.NewGuid();
|
||||
var group = new LearningGroup { SubjectId = subjectId, GradeLevel = 8 };
|
||||
var student = new Student { FirstName = "Ada", LastName = "Lovelace" };
|
||||
var k1 = new CompetencyItem { Code = "K1", Description = "Kompetenz" };
|
||||
var units = new FakeUnits();
|
||||
units.Add(new Unit
|
||||
{
|
||||
GroupId = group.Id, Status = UnitStatus.Completed, Competencies = ["K1"],
|
||||
GroupId = group.Id, Status = UnitStatus.Completed,
|
||||
Competencies = [new CompetencyReference { ItemId = k1.Id, Code = k1.Code, Description = k1.Description }],
|
||||
});
|
||||
var exam = new Exam
|
||||
{
|
||||
GroupId = group.Id,
|
||||
Status = ExamStatus.Graded,
|
||||
Tasks = [new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }],
|
||||
Tasks = [new ExamTask { MaxPoints = 10, Competencies = [new CompetencyReference { ItemId = k1.Id, Code = k1.Code, Description = k1.Description }] }],
|
||||
};
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = student.Id, Points = [5] });
|
||||
@@ -35,7 +37,7 @@ public sealed class CompetencyOverviewViewModelTests
|
||||
GradeLevel = 8,
|
||||
VersionId = version.Id,
|
||||
Name = "Bereich",
|
||||
Items = [new CompetencyItem { Code = "K1", Description = "Kompetenz" }],
|
||||
Items = [k1],
|
||||
});
|
||||
var vm = new CompetencyOverviewTabViewModel(units, new FakeExams([exam]), results,
|
||||
catalog, versions, new FakeStudents([student]), new CompetencyAnalysisService());
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class CompetencyTagGroupBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_MarkiertAusgewaehlteItemsAlsSelected()
|
||||
{
|
||||
var item = new CompetencyItem { Code = "K1", Description = "Erste Kompetenz" };
|
||||
var catalog = new List<CompetencyDomain> { new() { Name = "Bereich", Items = [item] } };
|
||||
var selected = new List<CompetencyReference> { new() { ItemId = item.Id, Code = item.Code, Description = item.Description } };
|
||||
|
||||
var groups = CompetencyTagGroupBuilder.Build(catalog, selected, _ => { });
|
||||
|
||||
var group = Assert.Single(groups);
|
||||
var tag = Assert.Single(group.Items);
|
||||
Assert.True(tag.IsSelected);
|
||||
Assert.Equal(item.Id, tag.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReferenzOhneKatalogTreffer_LandetInEigenerGruppeMitMomentaufnahme()
|
||||
{
|
||||
var catalog = new List<CompetencyDomain>
|
||||
{
|
||||
new() { Name = "Bereich", Items = [new CompetencyItem { Code = "K1", Description = "Erste Kompetenz" }] },
|
||||
};
|
||||
var orphaned = new CompetencyReference
|
||||
{
|
||||
ItemId = Guid.NewGuid(), Code = "ALT1", Description = "Alte, gelöschte Kompetenz",
|
||||
};
|
||||
var selected = new List<CompetencyReference> { orphaned };
|
||||
|
||||
var groups = CompetencyTagGroupBuilder.Build(catalog, selected, _ => { });
|
||||
|
||||
Assert.Equal(2, groups.Count);
|
||||
var fallbackGroup = groups.Single(g => g.Name == "Nicht mehr im Katalog");
|
||||
var tag = Assert.Single(fallbackGroup.Items);
|
||||
Assert.True(tag.IsSelected);
|
||||
Assert.Equal("ALT1", tag.Code);
|
||||
Assert.Equal("Alte, gelöschte Kompetenz", tag.Description);
|
||||
// Der reguläre Katalog-Bereich bleibt unberührt und zeigt sein einziges Item als abwählbar.
|
||||
var catalogGroup = groups.Single(g => g.Name == "Bereich");
|
||||
Assert.False(Assert.Single(catalogGroup.Items).IsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ToggleAufTag_RuftOnChangedMitDerTagInstanzAuf()
|
||||
{
|
||||
var item = new CompetencyItem { Code = "K1", Description = "Erste Kompetenz" };
|
||||
var catalog = new List<CompetencyDomain> { new() { Name = "Bereich", Items = [item] } };
|
||||
CompetencyTag? changed = null;
|
||||
|
||||
var groups = CompetencyTagGroupBuilder.Build(catalog, [], tag => changed = tag);
|
||||
var tag = Assert.Single(Assert.Single(groups).Items);
|
||||
tag.IsSelected = true;
|
||||
|
||||
Assert.Same(tag, changed);
|
||||
Assert.Equal(item.Id, changed!.ItemId);
|
||||
}
|
||||
}
|
||||
@@ -397,6 +397,7 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository
|
||||
public List<CompetencyDomain> GetBySubjectAndVersion(Guid subjectId, Guid versionId) =>
|
||||
_all.Where(d => d.SubjectId == subjectId && d.VersionId == versionId)
|
||||
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder).ToList();
|
||||
public List<CompetencyDomain> GetAll() => _all.ToList();
|
||||
public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id);
|
||||
public void Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); }
|
||||
public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id);
|
||||
|
||||
@@ -258,9 +258,11 @@ public sealed class McpToolsTests
|
||||
|
||||
private static LessonPlanTools BuildLessonPlanTools(
|
||||
FakeUnits? units = null, FakeLessons? lessons = null, FakeGroups? groups = null,
|
||||
FakeAttachmentStorage? attachments = null, FakeMcpConfirmation? confirmation = null) =>
|
||||
FakeAttachmentStorage? attachments = null, FakeCompetencyDomains? competencyDomains = null,
|
||||
FakeMcpConfirmation? confirmation = null) =>
|
||||
new(units ?? new FakeUnits(), lessons ?? new FakeLessons(), groups ?? new FakeGroups([]),
|
||||
attachments ?? new FakeAttachmentStorage(), confirmation ?? new FakeMcpConfirmation());
|
||||
attachments ?? new FakeAttachmentStorage(), competencyDomains ?? new FakeCompetencyDomains(),
|
||||
confirmation ?? new FakeMcpConfirmation());
|
||||
|
||||
[Fact]
|
||||
public void GetLessonPlans_LiefertEinheitenDerGruppeUndStundenImZeitraum()
|
||||
@@ -671,31 +673,58 @@ public sealed class McpToolsTests
|
||||
|
||||
// ── LessonPlanTools — Kompetenzzuordnung ────────────────────────────────────────────────────
|
||||
|
||||
private static FakeCompetencyDomains CatalogWithItem(out CompetencyItem item)
|
||||
{
|
||||
item = new CompetencyItem { Code = "PH.9.1", Description = "Brechungsgesetz anwenden" };
|
||||
var domains = new FakeCompetencyDomains();
|
||||
domains.Add(new CompetencyDomain { Name = "Optik", Items = [item] });
|
||||
return domains;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddLessonCompetency_NutzerBestaetigt_OrdnetCodeZu()
|
||||
public async Task AddLessonCompetency_NutzerBestaetigt_OrdnetKompetenzZu()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||
var domains = CatalogWithItem(out var item);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, competencyDomains: domains);
|
||||
|
||||
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
|
||||
var result = await tool.AddLessonCompetency(lesson.Id, item.Id);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Contains("PH.9.1", lessons.GetById(lesson.Id)!.Competencies);
|
||||
var stored = Assert.Single(lessons.GetById(lesson.Id)!.Competencies);
|
||||
Assert.Equal(item.Id, stored.ItemId);
|
||||
Assert.Equal("PH.9.1", stored.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddLessonCompetency_UnbekannteId_LiefertFehlerOhneNachfrage()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||
|
||||
var result = await tool.AddLessonCompetency(lesson.Id, Guid.NewGuid());
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(0, confirmation.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddLessonCompetency_BereitsZugeordnet_FragtNichtNochmalNach()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
lesson.Competencies.Add("PH.9.1");
|
||||
var domains = CatalogWithItem(out var item);
|
||||
lesson.Competencies.Add(new CompetencyReference { ItemId = item.Id, Code = item.Code, Description = item.Description });
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, competencyDomains: domains, confirmation: confirmation);
|
||||
|
||||
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
|
||||
var result = await tool.AddLessonCompetency(lesson.Id, item.Id);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Equal(0, confirmation.CallCount);
|
||||
@@ -703,15 +732,16 @@ public sealed class McpToolsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveLessonCompetency_NutzerBestaetigt_EntferntCode()
|
||||
public async Task RemoveLessonCompetency_NutzerBestaetigt_EntferntKompetenz()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
lesson.Competencies.Add("PH.9.1");
|
||||
var item = new CompetencyItem { Code = "PH.9.1", Description = "Brechungsgesetz anwenden" };
|
||||
lesson.Competencies.Add(new CompetencyReference { ItemId = item.Id, Code = item.Code, Description = item.Description });
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||
|
||||
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
|
||||
var result = await tool.RemoveLessonCompetency(lesson.Id, item.Id);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Empty(lessons.GetById(lesson.Id)!.Competencies);
|
||||
@@ -726,7 +756,7 @@ public sealed class McpToolsTests
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||
|
||||
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
|
||||
var result = await tool.RemoveLessonCompetency(lesson.Id, Guid.NewGuid());
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(0, confirmation.CallCount);
|
||||
|
||||
@@ -15,7 +15,7 @@ public sealed class ParticipationSessionEditingTests
|
||||
Date = new DateOnly(2026, 8, 19),
|
||||
Comment = "Sitzplan",
|
||||
LessonId = Guid.NewGuid(),
|
||||
CompetencyCodes = ["K1"],
|
||||
Competencies = [new CompetencyReference { ItemId = Guid.NewGuid(), Code = "K1", Description = "Kompetenz" }],
|
||||
};
|
||||
var vm = new AddSessionDialogViewModel(session)
|
||||
{
|
||||
@@ -29,7 +29,7 @@ public sealed class ParticipationSessionEditingTests
|
||||
Assert.Equal(new DateOnly(2026, 8, 20), session.Date);
|
||||
Assert.Equal("Elektrische Stromkreise", session.Comment);
|
||||
Assert.NotNull(session.LessonId);
|
||||
Assert.Equal(["K1"], session.CompetencyCodes);
|
||||
Assert.Equal(["K1"], session.Competencies.Select(c => c.Code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -23,7 +23,11 @@ public sealed class PlanningExchangeServiceTests
|
||||
Title = "Optik",
|
||||
StartDate = new DateOnly(2026, 9, 1),
|
||||
EndDate = new DateOnly(2026, 10, 1),
|
||||
Competencies = ["UF1", "E4"],
|
||||
Competencies =
|
||||
[
|
||||
new CompetencyReference { ItemId = Guid.NewGuid(), Code = "UF1", Description = "" },
|
||||
new CompetencyReference { ItemId = Guid.NewGuid(), Code = "E4", Description = "" },
|
||||
],
|
||||
Status = UnitStatus.Active,
|
||||
Notes = "Experimente",
|
||||
};
|
||||
@@ -61,7 +65,11 @@ public sealed class PlanningExchangeServiceTests
|
||||
Assert.Equal(targetGroupId, result.Unit.GroupId);
|
||||
Assert.Equal("Optik", result.Unit.Title);
|
||||
Assert.Equal(UnitStatus.Active, result.Unit.Status);
|
||||
Assert.Equal(["UF1", "E4"], result.Unit.Competencies);
|
||||
// Codes werden exportiert/importiert (Momentaufnahme-Text), aber bewusst NICHT gegen den
|
||||
// Katalog der Zielinstallation aufgelöst - jede importierte Referenz bleibt ItemId=Guid.Empty
|
||||
// (siehe PlanningExchangeService-Klassenkommentar: nie interne IDs übertragen).
|
||||
Assert.Equal(["UF1", "E4"], result.Unit.Competencies.Select(c => c.Code));
|
||||
Assert.All(result.Unit.Competencies, c => Assert.Equal(Guid.Empty, c.ItemId));
|
||||
var importedLesson = Assert.Single(result.Lessons);
|
||||
Assert.NotEqual(sourceLesson.Id, importedLesson.Id);
|
||||
Assert.Equal(result.Unit.Id, importedLesson.UnitId);
|
||||
|
||||
@@ -205,7 +205,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
Title = unit.Title,
|
||||
StartDate = unit.StartDate,
|
||||
EndDate = unit.EndDate,
|
||||
Competencies = unit.Competencies,
|
||||
Competencies = unit.Competencies.Select(c => c.Code).ToList(),
|
||||
Notes = unit.Notes,
|
||||
SubjectName = subject?.Name ?? "",
|
||||
GradeLevel = gradeLevel,
|
||||
|
||||
@@ -34,9 +34,14 @@ public record LessonPhaseDto(
|
||||
|
||||
public record LessonAttachmentDto(string StorageId, string FileName, long SizeBytes);
|
||||
|
||||
/// <summary>Eine über MCP zugeordnete Kompetenz - Id + zum Zuordnungszeitpunkt gespeicherte
|
||||
/// Momentaufnahme aus Code/Description (siehe CompetencyReference), damit ein KI-Client sie ohne
|
||||
/// Zusatzabfrage lesen kann, auch wenn das Katalog-Item inzwischen gelöscht wurde.</summary>
|
||||
public record CompetencyReferenceDto(Guid ItemId, string Code, string Description);
|
||||
|
||||
public record LessonDto(
|
||||
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
|
||||
string? Homework, string? PlanningIdeas, LessonStatus Status, List<string> Competencies,
|
||||
string? Homework, string? PlanningIdeas, LessonStatus Status, List<CompetencyReferenceDto> Competencies,
|
||||
List<LessonPhaseDto> Phases, List<LessonAttachmentDto> Attachments);
|
||||
|
||||
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten
|
||||
@@ -47,7 +52,7 @@ public record AttachmentContentDto(string FileName, long SizeBytes, string Base6
|
||||
|
||||
public record UnitDto(
|
||||
Guid Id, Guid GroupId, string Title, DateOnly? StartDate, DateOnly? EndDate,
|
||||
UnitStatus Status, List<string> Competencies);
|
||||
UnitStatus Status, List<CompetencyReferenceDto> Competencies);
|
||||
|
||||
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons);
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
/// (siehe McpServerHostedService) und mit besonders deutlicher Bestätigungsnachricht.</summary>
|
||||
public class LessonPlanTools(
|
||||
IUnitRepository units, ILessonRepository lessons, IGroupRepository groups,
|
||||
IAttachmentStorage attachments, IMcpConfirmationService confirmation)
|
||||
IAttachmentStorage attachments, ICompetencyDomainRepository competencyDomains,
|
||||
IMcpConfirmationService confirmation)
|
||||
{
|
||||
/// <summary>Deckelt die Antwortgröße von "download_lesson_attachment" (Base64 bläht ca. um
|
||||
/// Faktor 1,33 auf). Kleiner als <see cref="IAttachmentStorage.MaxSizeBytes"/> (App-weites
|
||||
@@ -42,7 +43,7 @@ public class LessonPlanTools(
|
||||
[Description("Enddatum (einschließlich) für die Einzelstunden, Format YYYY-MM-DD.")] DateOnly to)
|
||||
{
|
||||
var unitDtos = units.GetByGroup(groupId)
|
||||
.Select(u => new UnitDto(u.Id, u.GroupId, u.Title, u.StartDate, u.EndDate, u.Status, u.Competencies))
|
||||
.Select(u => new UnitDto(u.Id, u.GroupId, u.Title, u.StartDate, u.EndDate, u.Status, ToDto(u.Competencies)))
|
||||
.ToList();
|
||||
var lessonDtos = lessons.GetByGroupAndRange(groupId, from, to)
|
||||
.Select(ToDto)
|
||||
@@ -311,40 +312,40 @@ public class LessonPlanTools(
|
||||
// Race-Condition-Überlegung wie bei den Verlaufsplan-Phasen (add/update/remove_lesson_phase) -
|
||||
// ein Tool-Aufruf ändert nur einen Code, kein "letzter Schreiber gewinnt" über die ganze Liste.
|
||||
|
||||
[Description("Ordnet einer Einzelstunde einen Kompetenzcode aus dem Katalog zu (siehe get_competency_catalog). Muss der Nutzer erst bestätigen.")]
|
||||
[Description("Ordnet einer Einzelstunde eine Kompetenz aus dem Katalog zu (siehe get_competency_catalog). Muss der Nutzer erst bestätigen.")]
|
||||
public async Task<WriteResultDto> AddLessonCompetency(
|
||||
[Description("ID der Einzelstunde.")] Guid lessonId,
|
||||
[Description("Kompetenzcode, z.B. \"M.5.1\".")] string code,
|
||||
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var lesson = lessons.GetById(lessonId);
|
||||
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
||||
code = code.Trim();
|
||||
if (code.Length == 0) return new WriteResultDto(false, null, "Leerer Kompetenzcode.");
|
||||
if (lesson.Competencies.Contains(code, StringComparer.OrdinalIgnoreCase))
|
||||
return new WriteResultDto(true, lesson.Id, "Kompetenzcode ist bereits zugeordnet.");
|
||||
var item = FindCompetencyItem(itemId);
|
||||
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID.");
|
||||
if (lesson.Competencies.Any(c => c.ItemId == itemId))
|
||||
return new WriteResultDto(true, lesson.Id, "Kompetenz ist bereits zugeordnet.");
|
||||
|
||||
var message = $"Kompetenz „{code}“ zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?";
|
||||
var message = $"Kompetenz „{item.Code}“ zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?";
|
||||
if (!await confirmation.ConfirmAsync("Kompetenz zuordnen?", message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
lesson.Competencies.Add(code);
|
||||
lesson.Competencies.Add(new CompetencyReference { ItemId = item.Id, Code = item.Code, Description = item.Description });
|
||||
lessons.Save(lesson);
|
||||
return new WriteResultDto(true, lesson.Id, "Kompetenz zugeordnet.");
|
||||
}
|
||||
|
||||
[Description("Entfernt einen Kompetenzcode von einer Einzelstunde. Muss der Nutzer erst bestätigen.")]
|
||||
[Description("Entfernt eine Kompetenz von einer Einzelstunde. Muss der Nutzer erst bestätigen.")]
|
||||
public async Task<WriteResultDto> RemoveLessonCompetency(
|
||||
[Description("ID der Einzelstunde.")] Guid lessonId,
|
||||
[Description("Kompetenzcode, wie in get_lesson_plans hinterlegt.")] string code,
|
||||
[Description("ID der Einzelkompetenz, wie in get_lesson_plans hinterlegt.")] Guid itemId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var lesson = lessons.GetById(lessonId);
|
||||
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
||||
var existing = lesson.Competencies.FirstOrDefault(c => string.Equals(c, code, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing is null) return new WriteResultDto(false, null, "Dieser Kompetenzcode ist dieser Stunde nicht zugeordnet.");
|
||||
var existing = lesson.Competencies.FirstOrDefault(c => c.ItemId == itemId);
|
||||
if (existing is null) return new WriteResultDto(false, null, "Diese Kompetenz ist dieser Stunde nicht zugeordnet.");
|
||||
|
||||
var message = $"Kompetenz „{existing}“ von „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?";
|
||||
var message = $"Kompetenz „{existing.Code}“ von „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?";
|
||||
if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
@@ -353,6 +354,9 @@ public class LessonPlanTools(
|
||||
return new WriteResultDto(true, lesson.Id, "Kompetenz entfernt.");
|
||||
}
|
||||
|
||||
private CompetencyItem? FindCompetencyItem(Guid itemId) =>
|
||||
competencyDomains.GetAll().SelectMany(d => d.Items).FirstOrDefault(i => i.Id == itemId);
|
||||
|
||||
// ── Einzelstunden (Lesson) — Verschieben & Löschen ───────────────────────────────────────
|
||||
|
||||
[Description("Verschiebt eine Einzelstunde auf ein neues Datum (und optional eine neue Stundennummer). Mit shiftFollowingLessons=true verschieben sich alle noch nicht durchgeführten, späteren Stunden derselben Einheit um denselben Tages-Versatz mit — so lässt sich eine Lücke für eine neue Stunde öffnen: diese Stunde auf den Termin der übernächsten verschieben (mit shiftFollowingLessons), dann create_lesson auf das dadurch freigewordene ursprüngliche Datum. Muss der Nutzer erst bestätigen.")]
|
||||
@@ -414,7 +418,10 @@ public class LessonPlanTools(
|
||||
}
|
||||
|
||||
private static LessonDto ToDto(Lesson l) => new(
|
||||
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.PlanningIdeas, l.Status, l.Competencies,
|
||||
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.PlanningIdeas, l.Status, ToDto(l.Competencies),
|
||||
l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand, p.MaterialPrompt)).ToList(),
|
||||
l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList());
|
||||
|
||||
private static List<CompetencyReferenceDto> ToDto(List<CompetencyReference> competencies) =>
|
||||
competencies.Select(c => new CompetencyReferenceDto(c.ItemId, c.Code, c.Description)).ToList();
|
||||
}
|
||||
@@ -62,9 +62,16 @@ public sealed class PlanningExchangeService(IUnitRepository units, ILessonReposi
|
||||
Title = document.Unit.Title.Trim(),
|
||||
StartDate = document.Unit.StartDate,
|
||||
EndDate = document.Unit.EndDate,
|
||||
// Das Austauschformat überträgt bewusst nie interne DB-IDs ("jeder Import legt neue
|
||||
// Objekte an", siehe Klassenkommentar) - jeder importierte Code wird deshalb als NICHT
|
||||
// aufgelöste Referenz gespeichert (kein Matching gegen den Katalog der Zielinstallation,
|
||||
// das wäre wieder dasselbe fragile Code-Matching, das TODO 16.5 vermeiden soll, nur
|
||||
// installationsübergreifend). Die Tag-UI zeigt sie ehrlich als "Nicht mehr im Katalog";
|
||||
// der Nutzer kann sie bei Bedarf manuell dem echten Katalogeintrag neu zuordnen.
|
||||
Competencies = document.Unit.Competencies
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(c => new CompetencyReference { ItemId = Guid.Empty, Code = c }).ToList(),
|
||||
Status = document.Unit.Status,
|
||||
Notes = Clean(document.Unit.Notes),
|
||||
};
|
||||
@@ -94,7 +101,7 @@ public sealed class PlanningExchangeService(IUnitRepository units, ILessonReposi
|
||||
Title = unit.Title,
|
||||
StartDate = unit.StartDate,
|
||||
EndDate = unit.EndDate,
|
||||
Competencies = [.. unit.Competencies],
|
||||
Competencies = [.. unit.Competencies.Select(c => c.Code)],
|
||||
Status = unit.Status,
|
||||
Notes = unit.Notes,
|
||||
Lessons = [.. unitLessons.Select(ToPayload)],
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
// ── Kompetenz-Tags ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Gemeinsame Toggle-UI für die drei Stellen, an denen Kompetenzen aus dem Katalog einem anderen
|
||||
// Objekt zugeordnet werden (Unterrichtseinheit, Klausuraufgabe, Mitarbeit-Sitzung) - vor der
|
||||
// Umstellung auf CompetencyItem.Id (TODO 16.5) war die Bau-Logik dreifach fast identisch
|
||||
// dupliziert, jetzt an einer Stelle.
|
||||
|
||||
public class CompetencyTagGroup(string name, string code)
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public string Code { get; } = code;
|
||||
public string DisplayName { get; } = string.IsNullOrEmpty(code) ? name : $"{name} ({code})";
|
||||
public List<CompetencyTag> Items { get; } = [];
|
||||
}
|
||||
|
||||
public partial class CompetencyTag : ObservableObject
|
||||
{
|
||||
public Guid ItemId { get; }
|
||||
public string Code { get; }
|
||||
public string Description { get; }
|
||||
public string Display { get; }
|
||||
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
|
||||
/// <summary>Reicht die Tag-Instanz selbst durch, statt nur Id/Zustand - der Aufrufer liest
|
||||
/// Code/Description direkt vom Tag, ohne eine eigene Lookup-Tabelle führen zu müssen.</summary>
|
||||
public Action<CompetencyTag>? OnChanged { get; set; }
|
||||
|
||||
public CompetencyTag(Guid itemId, string code, string description, bool isSelected)
|
||||
{
|
||||
ItemId = itemId;
|
||||
Code = code;
|
||||
Description = description;
|
||||
Display = string.IsNullOrEmpty(code) ? description : $"[{code}] {description}";
|
||||
_isSelected = isSelected;
|
||||
}
|
||||
|
||||
partial void OnIsSelectedChanged(bool value) => OnChanged?.Invoke(this);
|
||||
}
|
||||
|
||||
public static class CompetencyTagGroupBuilder
|
||||
{
|
||||
/// <summary>Baut Tag-Gruppen aus dem aktuellen Katalog plus eine synthetische Gruppe "Nicht mehr
|
||||
/// im Katalog" für bereits zugeordnete Referenzen, deren ItemId im übergebenen Katalog nicht
|
||||
/// auflösbar ist (gelöscht, andere Bildungsplan-Version, oder nie aufgelöster Import) - zeigt
|
||||
/// deren gespeicherte Momentaufnahme, damit eine Zuordnung nie stillschweigend verschwindet
|
||||
/// (Kernanliegen von TODO 16.5). Tags der Katalog-Gruppen sind auf/abwählbar; Tags der
|
||||
/// "Nicht mehr im Katalog"-Gruppe sind immer bereits ausgewählt (es gibt keinen Katalogeintrag,
|
||||
/// dem man sie stattdessen zuordnen könnte) und lassen sich nur abwählen/entfernen.</summary>
|
||||
public static List<CompetencyTagGroup> Build(
|
||||
IReadOnlyList<CompetencyDomain> catalog, List<CompetencyReference> selected,
|
||||
Action<CompetencyTag> onChanged)
|
||||
{
|
||||
var groups = new List<CompetencyTagGroup>();
|
||||
var matchedIds = new HashSet<Guid>();
|
||||
|
||||
foreach (var domain in catalog.OrderBy(d => d.SortOrder))
|
||||
{
|
||||
var group = new CompetencyTagGroup(domain.Name, domain.Code);
|
||||
foreach (var item in domain.Items.OrderBy(i => i.SortOrder))
|
||||
{
|
||||
var isSelected = selected.Any(s => s.ItemId == item.Id);
|
||||
if (isSelected) matchedIds.Add(item.Id);
|
||||
group.Items.Add(new CompetencyTag(item.Id, item.Code, item.Description, isSelected)
|
||||
{
|
||||
OnChanged = onChanged,
|
||||
});
|
||||
}
|
||||
if (group.Items.Count > 0) groups.Add(group);
|
||||
}
|
||||
|
||||
var orphaned = selected.Where(s => !matchedIds.Contains(s.ItemId)).ToList();
|
||||
if (orphaned.Count > 0)
|
||||
{
|
||||
var group = new CompetencyTagGroup("Nicht mehr im Katalog", "");
|
||||
foreach (var reference in orphaned)
|
||||
group.Items.Add(new CompetencyTag(reference.ItemId, reference.Code, reference.Description, isSelected: true)
|
||||
{
|
||||
OnChanged = onChanged,
|
||||
});
|
||||
groups.Add(group);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
// gerade eine andere Niveau-Variante (E/G/Förder) derselben Klausur anzulegen.
|
||||
SelectedNiveauName = isDuplicate ? "–" : NiveauDisplay.ToName(source.Niveau);
|
||||
foreach (var t in source.Tasks.OrderBy(t => t.Nr))
|
||||
AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.CompetencyCodes]);
|
||||
AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.Competencies]);
|
||||
UseWeighting = Tasks.Any(t => Math.Abs(t.Weight - 1.0) > 0.0001);
|
||||
foreach (var e in source.GradingKey.OrderByDescending(e => e.MinPercent))
|
||||
AddGradingKeyRowInternal(e.Grade, e.MinPercent);
|
||||
@@ -148,10 +148,10 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
RecomputeTotals();
|
||||
}
|
||||
|
||||
private void AddTaskInternal(string? title, double maxPoints, double weight, List<string> competencyCodes)
|
||||
private void AddTaskInternal(string? title, double maxPoints, double weight, List<CompetencyReference> competencies)
|
||||
{
|
||||
var item = new ExamTaskEditItem(title, maxPoints, weight, competencyCodes,
|
||||
BuildCompetencyTagGroups(competencyCodes))
|
||||
var item = new ExamTaskEditItem(title, maxPoints, weight, competencies,
|
||||
BuildCompetencyTagGroups(competencies))
|
||||
{
|
||||
OnChanged = RecomputeTotals,
|
||||
OnRemove = RemoveTask,
|
||||
@@ -162,19 +162,13 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
RenumberTasks();
|
||||
}
|
||||
|
||||
private List<CompetencyTagGroup> BuildCompetencyTagGroups(List<string> selectedCodes)
|
||||
private List<CompetencyTagGroup> BuildCompetencyTagGroups(List<CompetencyReference> selected)
|
||||
{
|
||||
var groups = new List<CompetencyTagGroup>();
|
||||
if (!_subjectId.HasValue) return groups;
|
||||
var selected = selectedCodes.ToHashSet();
|
||||
foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId))
|
||||
{
|
||||
var group = new CompetencyTagGroup(domain.Name, domain.Code);
|
||||
foreach (var item in domain.Items.OrderBy(i => i.SortOrder))
|
||||
group.Items.Add(new CompetencyTag(item.Code, item.Description, selected.Contains(item.Code)));
|
||||
if (group.Items.Count > 0) groups.Add(group);
|
||||
}
|
||||
return groups;
|
||||
if (!_subjectId.HasValue) return [];
|
||||
var catalog = _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId);
|
||||
// onChanged wird hier bewusst nicht gesetzt (bleibt null) - ExamTaskEditItem verdrahtet
|
||||
// tag.OnChanged selbst auf seinen eigenen OnCompetencyToggled-Handler, siehe Konstruktor.
|
||||
return CompetencyTagGroupBuilder.Build(catalog, selected, _ => { });
|
||||
}
|
||||
|
||||
private void RemoveTask(ExamTaskEditItem item)
|
||||
@@ -368,12 +362,12 @@ public partial class ExamTaskEditItem : ObservableObject
|
||||
[ObservableProperty] private bool _isCompetencyPanelOpen;
|
||||
[ObservableProperty] private bool _showWeight;
|
||||
|
||||
public List<string> CompetencyCodes { get; }
|
||||
public List<CompetencyReference> Competencies { get; }
|
||||
public ObservableCollection<CompetencyTagGroup> CompetencyTagGroups { get; } = [];
|
||||
|
||||
public string CompetencySummary => CompetencyCodes.Count == 0
|
||||
public string CompetencySummary => Competencies.Count == 0
|
||||
? "Keine Kompetenzen"
|
||||
: $"{CompetencyCodes.Count} Kompetenz(en)";
|
||||
: $"{Competencies.Count} Kompetenz(en)";
|
||||
|
||||
public Action? OnChanged { get; set; }
|
||||
public Action<ExamTaskEditItem>? OnRemove { get; set; }
|
||||
@@ -381,12 +375,12 @@ public partial class ExamTaskEditItem : ObservableObject
|
||||
public Action<ExamTaskEditItem>? OnMoveDown { get; set; }
|
||||
|
||||
public ExamTaskEditItem(string? title, double maxPoints, double weight,
|
||||
List<string> competencyCodes, List<CompetencyTagGroup> tagGroups)
|
||||
List<CompetencyReference> competencies, List<CompetencyTagGroup> tagGroups)
|
||||
{
|
||||
_title = title ?? "";
|
||||
_maxPoints = maxPoints;
|
||||
_weight = weight;
|
||||
CompetencyCodes = competencyCodes;
|
||||
Competencies = competencies;
|
||||
foreach (var g in tagGroups)
|
||||
{
|
||||
foreach (var tag in g.Items) tag.OnChanged = OnCompetencyToggled;
|
||||
@@ -394,10 +388,17 @@ public partial class ExamTaskEditItem : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCompetencyToggled(string code, bool selected)
|
||||
private void OnCompetencyToggled(CompetencyTag tag)
|
||||
{
|
||||
if (selected) { if (!CompetencyCodes.Contains(code)) CompetencyCodes.Add(code); }
|
||||
else CompetencyCodes.Remove(code);
|
||||
if (tag.IsSelected)
|
||||
{
|
||||
if (!Competencies.Any(r => r.ItemId == tag.ItemId))
|
||||
Competencies.Add(new CompetencyReference { ItemId = tag.ItemId, Code = tag.Code, Description = tag.Description });
|
||||
}
|
||||
else
|
||||
{
|
||||
Competencies.RemoveAll(r => r.ItemId == tag.ItemId);
|
||||
}
|
||||
OnPropertyChanged(nameof(CompetencySummary));
|
||||
OnChanged?.Invoke();
|
||||
}
|
||||
@@ -415,6 +416,6 @@ public partial class ExamTaskEditItem : ObservableObject
|
||||
Title = string.IsNullOrWhiteSpace(Title) ? null : Title.Trim(),
|
||||
MaxPoints = MaxPoints,
|
||||
Weight = Weight,
|
||||
CompetencyCodes = CompetencyCodes,
|
||||
Competencies = Competencies,
|
||||
};
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _isReadOnly;
|
||||
|
||||
public string SelectedSessionDisplay => SelectedSession?.Display ?? "";
|
||||
public List<string> ActiveCompetencyCodes { get; private set; } = [];
|
||||
public List<CompetencyReference> ActiveCompetencies { get; private set; } = [];
|
||||
|
||||
public ObservableCollection<ParticipationSessionItem> Sessions { get; } = [];
|
||||
public ObservableCollection<ParticipationStudentRow> StudentRows { get; } = [];
|
||||
@@ -132,14 +132,14 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
{
|
||||
StudentRows.Clear();
|
||||
CompetencyTagGroups.Clear();
|
||||
ActiveCompetencyCodes = [];
|
||||
ActiveCompetencies = [];
|
||||
QuickInputCommand.NotifyCanExecuteChanged();
|
||||
StatusQuickInputCommand.NotifyCanExecuteChanged();
|
||||
RebuildColumnsSignal++;
|
||||
return;
|
||||
}
|
||||
LoadCompetencyTags(value.Id); // sets ActiveCompetencyCodes first
|
||||
LoadGrid(value.Id); // uses ActiveCompetencyCodes, fires RebuildColumnsSignal++
|
||||
LoadCompetencyTags(value.Id); // sets ActiveCompetencies first
|
||||
LoadGrid(value.Id); // uses ActiveCompetencies, fires RebuildColumnsSignal++
|
||||
}
|
||||
|
||||
private void LoadGrid(Guid sessionId)
|
||||
@@ -159,7 +159,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
|
||||
var entry = entries.FirstOrDefault(e => e.StudentId == s.Id)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = s.Id };
|
||||
var row = new ParticipationStudentRow(s.Id, s.FullName, entry, Aspects.ToList(), ActiveCompetencyCodes);
|
||||
var row = new ParticipationStudentRow(s.Id, s.FullName, entry, Aspects.ToList(), ActiveCompetencies);
|
||||
row.OnRatingChanged = (sid, key, val) => SaveRating(sessionId, sid, key, val);
|
||||
row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val);
|
||||
row.HomeworkChangedCallback = (sid, val) => SaveHomework(sessionId, sid, val);
|
||||
@@ -237,35 +237,31 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
{
|
||||
CompetencyTagGroups.Clear();
|
||||
var session = _sessions.GetById(sessionId);
|
||||
ActiveCompetencyCodes = session?.CompetencyCodes?.ToList() ?? [];
|
||||
ActiveCompetencies = session?.Competencies?.ToList() ?? [];
|
||||
|
||||
if (!_subjectId.HasValue) return;
|
||||
var active = ActiveCompetencyCodes.ToHashSet();
|
||||
|
||||
foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId))
|
||||
{
|
||||
var group = new CompetencyTagGroup(domain.Name, domain.Code);
|
||||
foreach (var item in domain.Items.OrderBy(i => i.SortOrder))
|
||||
{
|
||||
var tag = new CompetencyTag(item.Code, item.Description, active.Contains(item.Code));
|
||||
tag.OnChanged = (code, sel) => OnTagToggled(code, sel);
|
||||
group.Items.Add(tag);
|
||||
}
|
||||
if (group.Items.Count > 0)
|
||||
CompetencyTagGroups.Add(group);
|
||||
}
|
||||
var catalog = _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId);
|
||||
foreach (var group in CompetencyTagGroupBuilder.Build(catalog, ActiveCompetencies, OnTagToggled))
|
||||
CompetencyTagGroups.Add(group);
|
||||
}
|
||||
|
||||
private void OnTagToggled(string code, bool selected)
|
||||
private void OnTagToggled(CompetencyTag tag)
|
||||
{
|
||||
if (SelectedSession is null) return;
|
||||
var session = _sessions.GetById(SelectedSession.Id);
|
||||
if (session is null) return;
|
||||
if (selected) { if (!session.CompetencyCodes.Contains(code)) session.CompetencyCodes.Add(code); }
|
||||
else { session.CompetencyCodes.Remove(code); }
|
||||
if (tag.IsSelected)
|
||||
{
|
||||
if (!session.Competencies.Any(r => r.ItemId == tag.ItemId))
|
||||
session.Competencies.Add(new CompetencyReference { ItemId = tag.ItemId, Code = tag.Code, Description = tag.Description });
|
||||
}
|
||||
else
|
||||
{
|
||||
session.Competencies.RemoveAll(r => r.ItemId == tag.ItemId);
|
||||
}
|
||||
_sessions.Save(session);
|
||||
|
||||
ActiveCompetencyCodes = session.CompetencyCodes.ToList();
|
||||
ActiveCompetencies = session.Competencies.ToList();
|
||||
if (StudentCompetencyRatingsVisible)
|
||||
LoadGrid(SelectedSession.Id); // reloads rows with updated cells, fires RebuildColumnsSignal++
|
||||
}
|
||||
@@ -369,22 +365,25 @@ public partial class ParticipationTabViewModel : ObservableObject
|
||||
_entries.Save(entry);
|
||||
}
|
||||
|
||||
private void SaveCompetencyRating(Guid sessionId, Guid studentId, string code, int? value)
|
||||
// "key" ist der Kompetenz-ItemId als String (siehe ParticipationStudentRow/RatingCell) - reiner
|
||||
// In-Memory-Transport zwischen Zelle und Save-Callback, nie persistiert.
|
||||
private void SaveCompetencyRating(Guid sessionId, Guid studentId, string key, int? value)
|
||||
{
|
||||
var itemId = Guid.Parse(key);
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry
|
||||
{
|
||||
SessionId = sessionId,
|
||||
StudentId = studentId,
|
||||
};
|
||||
var existing = entry.CompetencyRatings.FirstOrDefault(r => r.Code == code);
|
||||
var existing = entry.CompetencyRatings.FirstOrDefault(r => r.ItemId == itemId);
|
||||
if (value is null)
|
||||
{
|
||||
if (existing is not null) entry.CompetencyRatings.Remove(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (existing is null) entry.CompetencyRatings.Add(new CompetencyRating { Code = code, Value = value.Value });
|
||||
if (existing is null) entry.CompetencyRatings.Add(new CompetencyRating { ItemId = itemId, Value = value.Value });
|
||||
else existing.Value = value.Value;
|
||||
}
|
||||
_entries.Save(entry);
|
||||
@@ -425,7 +424,7 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
public Action<Guid, DayHighlightKind?>? DayHighlightChangedCallback { get; set; }
|
||||
|
||||
public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry,
|
||||
List<AspectColumnDef> aspects, List<string> competencyCodes)
|
||||
List<AspectColumnDef> aspects, List<CompetencyReference> competencies)
|
||||
{
|
||||
StudentId = id;
|
||||
Name = name;
|
||||
@@ -442,10 +441,12 @@ public partial class ParticipationStudentRow : ObservableObject
|
||||
Cells.Add(cell);
|
||||
}
|
||||
|
||||
foreach (var code in competencyCodes)
|
||||
// AspectKey trägt hier den Kompetenz-ItemId als String - reiner In-Memory-Transport
|
||||
// zwischen Zelle und Save-Callback (siehe SaveCompetencyRating), nie persistiert.
|
||||
foreach (var competency in competencies)
|
||||
{
|
||||
var existing = entry.CompetencyRatings.FirstOrDefault(r => r.Code == code);
|
||||
var cell = new RatingCell(id, code, existing?.Value);
|
||||
var existing = entry.CompetencyRatings.FirstOrDefault(r => r.ItemId == competency.ItemId);
|
||||
var cell = new RatingCell(id, competency.ItemId.ToString("D"), existing?.Value);
|
||||
cell.OnChanged = (sid, key, val) => OnCompetencyRatingChanged?.Invoke(sid, key, val);
|
||||
CompetencyCells.Add(cell);
|
||||
}
|
||||
@@ -726,37 +727,6 @@ public partial class RatingCell : ObservableObject
|
||||
private void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value);
|
||||
}
|
||||
|
||||
// ── Kompetenz-Tags ────────────────────────────────────────────────────────────
|
||||
|
||||
public class CompetencyTagGroup(string name, string code)
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public string Code { get; } = code;
|
||||
public string DisplayName { get; } = string.IsNullOrEmpty(code) ? name : $"{name} ({code})";
|
||||
public List<CompetencyTag> Items { get; } = [];
|
||||
}
|
||||
|
||||
public partial class CompetencyTag : ObservableObject
|
||||
{
|
||||
public string Code { get; }
|
||||
public string Description { get; }
|
||||
public string Display { get; }
|
||||
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
|
||||
public Action<string, bool>? OnChanged { get; set; }
|
||||
|
||||
public CompetencyTag(string code, string description, bool isSelected)
|
||||
{
|
||||
Code = code;
|
||||
Description = description;
|
||||
Display = string.IsNullOrEmpty(code) ? description : $"[{code}] {description}";
|
||||
_isSelected = isSelected;
|
||||
}
|
||||
|
||||
partial void OnIsSelectedChanged(bool value) => OnChanged?.Invoke(Code, value);
|
||||
}
|
||||
|
||||
// ── Hilfsklassen ──────────────────────────────────────────────────────────────
|
||||
|
||||
public class AspectColumnDef
|
||||
|
||||
@@ -586,7 +586,7 @@ public partial class UnitDialogViewModel : ObservableObject
|
||||
private readonly int _gradeLevel;
|
||||
private readonly Guid _competencyCatalogVersionId;
|
||||
private readonly Unit? _editingUnit;
|
||||
private readonly List<string> _competencyCodes;
|
||||
private readonly List<CompetencyReference> _competencyRefs;
|
||||
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string _startDateText = "";
|
||||
@@ -601,8 +601,8 @@ public partial class UnitDialogViewModel : ObservableObject
|
||||
|
||||
public string[] StatusOptions => UnitStatusDisplay.Options;
|
||||
public ObservableCollection<CompetencyTagGroup> CompetencyTagGroups { get; } = [];
|
||||
public string CompetencySummary => _competencyCodes.Count == 0
|
||||
? "Keine Kompetenzen" : $"{_competencyCodes.Count} Kompetenz(en)";
|
||||
public string CompetencySummary => _competencyRefs.Count == 0
|
||||
? "Keine Kompetenzen" : $"{_competencyRefs.Count} Kompetenz(en)";
|
||||
|
||||
/// Gruppe + Fach kommen von der Lerngruppe, nicht editierbar (jede Gruppe unterrichtet ein
|
||||
/// Fach) — beide zusammen zeigen, da dieselbe Klasse in mehreren Fächern (mehrere
|
||||
@@ -638,7 +638,7 @@ public partial class UnitDialogViewModel : ObservableObject
|
||||
GroupSubjectDisplay = string.IsNullOrWhiteSpace(subjectName)
|
||||
? $"{groupName} · kein Fach hinterlegt (siehe Lerngruppe)" : $"{groupName} · {subjectName}";
|
||||
|
||||
_competencyCodes = editingUnit is not null ? [.. editingUnit.Competencies] : [];
|
||||
_competencyRefs = editingUnit is not null ? [.. editingUnit.Competencies] : [];
|
||||
BuildCompetencyTagGroups();
|
||||
|
||||
if (editingUnit is not null)
|
||||
@@ -655,24 +655,22 @@ public partial class UnitDialogViewModel : ObservableObject
|
||||
{
|
||||
CompetencyTagGroups.Clear();
|
||||
if (!_subjectId.HasValue) return;
|
||||
var selected = _competencyCodes.ToHashSet();
|
||||
foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId))
|
||||
{
|
||||
var group = new CompetencyTagGroup(domain.Name, domain.Code);
|
||||
foreach (var item in domain.Items.OrderBy(i => i.SortOrder))
|
||||
{
|
||||
var tag = new CompetencyTag(item.Code, item.Description, selected.Contains(item.Code));
|
||||
tag.OnChanged = OnCompetencyToggled;
|
||||
group.Items.Add(tag);
|
||||
}
|
||||
if (group.Items.Count > 0) CompetencyTagGroups.Add(group);
|
||||
}
|
||||
var catalog = _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId);
|
||||
foreach (var group in CompetencyTagGroupBuilder.Build(catalog, _competencyRefs, OnCompetencyToggled))
|
||||
CompetencyTagGroups.Add(group);
|
||||
}
|
||||
|
||||
private void OnCompetencyToggled(string code, bool selected)
|
||||
private void OnCompetencyToggled(CompetencyTag tag)
|
||||
{
|
||||
if (selected) { if (!_competencyCodes.Contains(code)) _competencyCodes.Add(code); }
|
||||
else _competencyCodes.Remove(code);
|
||||
if (tag.IsSelected)
|
||||
{
|
||||
if (!_competencyRefs.Any(r => r.ItemId == tag.ItemId))
|
||||
_competencyRefs.Add(new CompetencyReference { ItemId = tag.ItemId, Code = tag.Code, Description = tag.Description });
|
||||
}
|
||||
else
|
||||
{
|
||||
_competencyRefs.RemoveAll(r => r.ItemId == tag.ItemId);
|
||||
}
|
||||
OnPropertyChanged(nameof(CompetencySummary));
|
||||
}
|
||||
|
||||
@@ -713,7 +711,7 @@ public partial class UnitDialogViewModel : ObservableObject
|
||||
Result.EndDate = endDate;
|
||||
Result.Status = UnitStatusDisplay.FromName(StatusName);
|
||||
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
|
||||
Result.Competencies = _competencyCodes;
|
||||
Result.Competencies = _competencyRefs;
|
||||
if (Result.Status == UnitStatus.Active)
|
||||
{
|
||||
// Pro Lerngruppe gibt es genau einen aktuellen Arbeitskontext. Frühere laufende
|
||||
|
||||
@@ -81,14 +81,14 @@ public partial class ParticipationTabView : UserControl
|
||||
});
|
||||
|
||||
// Kompetenz-Spalten (opt-in)
|
||||
if (_vm.StudentCompetencyRatingsVisible && _vm.ActiveCompetencyCodes.Count > 0)
|
||||
if (_vm.StudentCompetencyRatingsVisible && _vm.ActiveCompetencies.Count > 0)
|
||||
{
|
||||
foreach (var (code, i) in _vm.ActiveCompetencyCodes.Select((c, i) => (c, i)))
|
||||
foreach (var (competency, i) in _vm.ActiveCompetencies.Select((c, i) => (c, i)))
|
||||
{
|
||||
var idx = i;
|
||||
grid.Columns.Add(new DataGridTemplateColumn
|
||||
{
|
||||
Header = code,
|
||||
Header = string.IsNullOrEmpty(competency.Code) ? competency.Description : competency.Code,
|
||||
Width = new DataGridLength(80, DataGridLengthUnitType.Pixel),
|
||||
CellTemplate = BuildCellTemplate(idx, forCompetency: true, _vm.IsReadOnly),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,12 @@ public sealed class CompetencyAnalysisServiceTests
|
||||
{
|
||||
private readonly CompetencyAnalysisService _service = new();
|
||||
|
||||
private static readonly CompetencyItem K1 = new() { Code = "K1", Description = "Erste Kompetenz" };
|
||||
private static readonly CompetencyItem K2 = new() { Code = "K2", Description = "Zweite Kompetenz", SortOrder = 1 };
|
||||
|
||||
private static CompetencyReference Ref(CompetencyItem item) =>
|
||||
new() { ItemId = item.Id, Code = item.Code, Description = item.Description };
|
||||
|
||||
[Fact]
|
||||
public void Analyze_UnterscheidetPlanungBehandlungUndPruefung()
|
||||
{
|
||||
@@ -15,16 +21,16 @@ public sealed class CompetencyAnalysisServiceTests
|
||||
var catalog = Catalog();
|
||||
var units = new List<Unit>
|
||||
{
|
||||
new() { GroupId = groupId, Status = UnitStatus.Planned, Competencies = ["K1"] },
|
||||
new() { GroupId = groupId, Status = UnitStatus.Active, Competencies = ["K1"] },
|
||||
new() { GroupId = groupId, Status = UnitStatus.Completed, Competencies = ["K1"] },
|
||||
new() { GroupId = groupId, Status = UnitStatus.Planned, Competencies = [Ref(K1)] },
|
||||
new() { GroupId = groupId, Status = UnitStatus.Active, Competencies = [Ref(K1)] },
|
||||
new() { GroupId = groupId, Status = UnitStatus.Completed, Competencies = [Ref(K1)] },
|
||||
};
|
||||
var exams = new List<Exam>
|
||||
{
|
||||
new() { GroupId = groupId, Status = ExamStatus.Planned,
|
||||
Tasks = [new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }] },
|
||||
Tasks = [new ExamTask { MaxPoints = 10, Competencies = [Ref(K1)] }] },
|
||||
new() { GroupId = groupId, Status = ExamStatus.Graded,
|
||||
Tasks = [new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }] },
|
||||
Tasks = [new ExamTask { MaxPoints = 10, Competencies = [Ref(K1)] }] },
|
||||
};
|
||||
|
||||
var item = Assert.Single(_service.Analyze(catalog, units, exams, []));
|
||||
@@ -46,8 +52,8 @@ public sealed class CompetencyAnalysisServiceTests
|
||||
Status = ExamStatus.Graded,
|
||||
Tasks =
|
||||
[
|
||||
new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] },
|
||||
new ExamTask { MaxPoints = 20, CompetencyCodes = ["K1"] },
|
||||
new ExamTask { MaxPoints = 10, Competencies = [Ref(K1)] },
|
||||
new ExamTask { MaxPoints = 20, Competencies = [Ref(K1)] },
|
||||
],
|
||||
};
|
||||
var results = new List<ExamResult>
|
||||
@@ -69,13 +75,13 @@ public sealed class CompetencyAnalysisServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_AufgabeMitMehrerenCodes_WertetJedeZugeordneteKompetenzAus()
|
||||
public void Analyze_AufgabeMitMehrerenKompetenzen_WertetJedeZugeordneteKompetenzAus()
|
||||
{
|
||||
var studentId = Guid.NewGuid();
|
||||
var exam = new Exam
|
||||
{
|
||||
Status = ExamStatus.Returned,
|
||||
Tasks = [new ExamTask { MaxPoints = 8, CompetencyCodes = ["K1", "K2"] }],
|
||||
Tasks = [new ExamTask { MaxPoints = 8, Competencies = [Ref(K1), Ref(K2)] }],
|
||||
};
|
||||
var result = new ExamResult { ExamId = exam.Id, StudentId = studentId, Points = [6] };
|
||||
|
||||
@@ -91,7 +97,7 @@ public sealed class CompetencyAnalysisServiceTests
|
||||
var exam = new Exam
|
||||
{
|
||||
Status = ExamStatus.Conducted,
|
||||
Tasks = [new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }],
|
||||
Tasks = [new ExamTask { MaxPoints = 10, Competencies = [Ref(K1)] }],
|
||||
};
|
||||
var result = new ExamResult { ExamId = exam.Id, StudentId = studentId, Points = [5] };
|
||||
|
||||
@@ -103,30 +109,59 @@ public sealed class CompetencyAnalysisServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_VerwendeterUnbekannterCode_BleibtInUebersichtSichtbar()
|
||||
public void Analyze_GeloeschteKompetenz_BleibtMitGespeicherterMomentaufnahmeSichtbar()
|
||||
{
|
||||
var unit = new Unit { Status = UnitStatus.Active, Competencies = ["ALT1"] };
|
||||
// ItemId zeigt auf kein (mehr) existierendes Katalog-Item (z.B. gelöscht) - Code/Description
|
||||
// stammen aus der zum Zuordnungszeitpunkt gespeicherten Momentaufnahme.
|
||||
var reference = new CompetencyReference
|
||||
{
|
||||
ItemId = Guid.NewGuid(), Code = "ALT1", Description = "Alte, inzwischen gelöschte Kompetenz",
|
||||
};
|
||||
var unit = new Unit { Status = UnitStatus.Active, Competencies = [reference] };
|
||||
|
||||
var analysis = _service.Analyze(Catalog(), [unit], [], []);
|
||||
|
||||
var unknown = Assert.Single(analysis, x => x.Code == "ALT1");
|
||||
Assert.Equal("Nicht im Katalog", unknown.DomainName);
|
||||
Assert.Equal("Alte, inzwischen gelöschte Kompetenz", unknown.Description);
|
||||
Assert.Equal(1, unknown.TaughtUnitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_NieAufgeloesteReferenz_OhneBeschreibung_ZeigtFallbacktext()
|
||||
{
|
||||
// ItemId == Guid.Empty simuliert eine nie aufgelöste Referenz (z.B. aus einem
|
||||
// plattformübergreifenden Import über PlanningExchangeService) ohne gespeicherte Beschreibung.
|
||||
var reference = new CompetencyReference { ItemId = Guid.Empty, Code = "IMPORT1", Description = "" };
|
||||
var unit = new Unit { Status = UnitStatus.Active, Competencies = [reference] };
|
||||
|
||||
var analysis = _service.Analyze(Catalog(), [unit], [], []);
|
||||
|
||||
var unknown = Assert.Single(analysis, x => x.Code == "IMPORT1");
|
||||
Assert.Contains("nicht (mehr) im aktuellen Katalog", unknown.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_UmbenannteKompetenz_ZeigtLiveFassungStattMomentaufnahme()
|
||||
{
|
||||
// Referenz trägt eine veraltete Momentaufnahme (Description von vor dem Rename) - solange
|
||||
// ItemId noch im Katalog auflösbar ist, muss die LIVE-Fassung gewinnen.
|
||||
var reference = new CompetencyReference { ItemId = K1.Id, Code = "K1", Description = "Veraltete Beschreibung" };
|
||||
var unit = new Unit { Status = UnitStatus.Active, Competencies = [reference] };
|
||||
|
||||
var item = Assert.Single(_service.Analyze(Catalog(), [unit], [], []));
|
||||
|
||||
Assert.Equal("Erste Kompetenz", item.Description);
|
||||
Assert.Equal("Bereich", item.DomainName);
|
||||
}
|
||||
|
||||
private static List<CompetencyDomain> Catalog(bool includeSecond = false) =>
|
||||
[
|
||||
new CompetencyDomain
|
||||
{
|
||||
Name = "Bereich",
|
||||
Code = "B",
|
||||
Items = includeSecond
|
||||
?
|
||||
[
|
||||
new CompetencyItem { Code = "K1", Description = "Erste Kompetenz", SortOrder = 0 },
|
||||
new CompetencyItem { Code = "K2", Description = "Zweite Kompetenz", SortOrder = 1 },
|
||||
]
|
||||
: [new CompetencyItem { Code = "K1", Description = "Erste Kompetenz" }],
|
||||
Items = includeSecond ? [K1, K2] : [K1],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -183,6 +183,7 @@ public sealed class CompetencyCatalogImportServiceTests
|
||||
Current.Where(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel && x.VersionId == versionId).ToList();
|
||||
public List<CompetencyDomain> GetBySubjectAndVersion(Guid subjectId, Guid versionId) =>
|
||||
Current.Where(x => x.SubjectId == subjectId && x.VersionId == versionId).ToList();
|
||||
public List<CompetencyDomain> GetAll() => Current.ToList();
|
||||
public CompetencyDomain? GetById(Guid id) => Current.FirstOrDefault(x => x.Id == id);
|
||||
|
||||
public void Save(CompetencyDomain domain) => Current.Add(domain);
|
||||
|
||||
@@ -4852,9 +4852,26 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
|
||||
- [ ] **16.4** Redundant gespeicherte Bewertungswerte (`ExamResult.TotalPoints`, berechnete Note)
|
||||
entweder ableiten oder zusammen mit einer unveränderlichen Version des verwendeten
|
||||
Notenschlüssels als historischen Snapshot speichern.
|
||||
- [ ] **16.5** Kompetenzzuordnungen auf stabile `CompetencyItem.Id` umstellen; Code/Beschreibung bei
|
||||
- [x] **16.5** Kompetenzzuordnungen auf stabile `CompetencyItem.Id` umstellen; Code/Beschreibung bei
|
||||
Bedarf zusätzlich als historischen Snapshot speichern, damit Umbenennungen keine alten
|
||||
Klausur- oder Sitzungsreferenzen brechen.
|
||||
Neuer Werttyp `CompetencyReference` (`ItemId` + Code/Description-Momentaufnahme, siehe
|
||||
Datenmodell.md "Bewusst gespeicherte Momentaufnahmen") ersetzt `List<string>` in
|
||||
`Unit.Competencies`, `Lesson.Competencies`, `ExamTask.Competencies` (umbenannt von
|
||||
`CompetencyCodes`), `ParticipationSession.Competencies` (umbenannt) und
|
||||
`CompetencyRating.ItemId` (umbenannt von `.Code`). Solange `ItemId` noch im Katalog auflösbar
|
||||
ist, zeigt die Analyse/UI die live Fassung (Rename wirkt sofort); ohne Treffer erscheint die
|
||||
Momentaufnahme in einer eigenen "Nicht mehr im Katalog"-Gruppe, statt zu verschwinden.
|
||||
Migration (Schema-Version 6, `LiteDbContext.MigrateCompetencyReferences`) löst alte Codes
|
||||
gegen den bestehenden Katalog auf; unauflösbare Codes bleiben als Text erhalten
|
||||
(`ItemId = Guid.Empty`), unauflösbare Mitarbeit-Ratings werden verworfen. Die dreifach
|
||||
duplizierte Tag-Auswahl-UI (Einheit/Klausuraufgabe/Mitarbeit-Sitzung) wurde dabei in
|
||||
`CompetencyTagGroupBuilder`/`CompetencyTagging.cs` konsolidiert. MCP:
|
||||
`add_lesson_competency`/`remove_lesson_competency` nehmen jetzt `itemId` statt `code` und
|
||||
validieren gegen den Katalog (vorher ungeprüft). `PlanningExchangeService` überträgt weiterhin
|
||||
nur Code-Text (nie IDs, wie im Format ohnehin vorgesehen) und speichert importierte
|
||||
Kompetenzen bewusst als nicht aufgelöste Referenz statt sie gegen den Katalog der
|
||||
Zielinstallation zu raten.
|
||||
- [ ] **16.6** Navigation und manuelle Callback-Verdrahtung in `App.axaml.cs` langfristig durch
|
||||
einen testbaren Navigationsdienst oder Messenger ersetzen; statischen Servicezugriff abbauen.
|
||||
|
||||
|
||||
@@ -98,6 +98,18 @@ Einige berechnete Werte bleiben absichtlich gespeichert:
|
||||
- `Exam.GradingKey` hält den für die konkrete Klausur verwendeten Notenschlüssel
|
||||
fest und ist unabhängig von später geänderten Vorlagen.
|
||||
- `Exam.Tasks` hält die Aufgabenstruktur der konkreten Klausur fest.
|
||||
- `CompetencyReference` (`Unit.Competencies`, `Lesson.Competencies`, `ExamTask.Competencies`,
|
||||
`ParticipationSession.Competencies`) verweist über `ItemId` auf ein `CompetencyItem`, hält aber
|
||||
zusätzlich `Code`/`Description` zum Zuordnungszeitpunkt fest (TODO 16.5, abgelöst von einer reinen
|
||||
Freitext-Code-Zuordnung). Solange `ItemId` noch im Katalog auflösbar ist, zeigt die Anwendung die
|
||||
**live** Fassung (ein Rename im Katalog wirkt sich überall sofort aus); wird das Item gelöscht,
|
||||
bleibt die hier gespeicherte historische Fassung lesbar statt spurlos zu verschwinden.
|
||||
`ItemId == Guid.Empty` markiert eine nie aufgelöste Referenz (z.B. aus einem
|
||||
plattformübergreifenden `PlanningExchangeService`-Import, der bewusst keine installationsfremden
|
||||
IDs überträgt) — Code/Description sind dann die einzige Information.
|
||||
`CompetencyRating.ItemId` (Mitarbeit, je Schüler) referenziert ebenso per Id, aber ohne eigenen
|
||||
Snapshot — die Identität kommt aus der Zuordnungsliste der zugehörigen `ParticipationSession`,
|
||||
die bereits eine Momentaufnahme trägt.
|
||||
|
||||
Änderungen an Aufgaben oder Notenschlüssel müssen die betroffenen Ergebnisse
|
||||
kontrolliert neu berechnen. Diese Werte sind daher fachliche Momentaufnahmen und
|
||||
|
||||
Reference in new issue
Block a user