feat: Bildungsplan-Versionierung für Kompetenzkataloge, vollständig über MCP exponiert

Neue Bildungspläne wachsen je Fach klassenstufenweise hoch, sodass Kurse abhängig vom
Einschulungsjahr entweder dem alten oder dem neuen Bildungsplan folgen. CompetencyCatalogVersion
(Fach + Name + IsDefault) macht das abbildbar: CompetencyDomain bekommt eine VersionId, Kurse
wählen über LearningGroup.CompetencyCatalogVersionId (null = Standardversion des Fachs) welche
Version gilt. Migration legt für bestehende Kataloge automatisch eine Standardversion an, damit
alte Datenbanken unverändert weiterlaufen.

MCP: neue Tools für Versions-CRUD (get/create/update/delete_competency_plan_version,
update_group_competency_version), get_competency_catalog/create_competency_domain um optionalen
versionId-Parameter erweitert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-19 00:07:29 +02:00
1 parent 4ffba35eff
commit f97edff2fe
45 files changed
+1020 -163

No files matched your search

+22 -7
View File
@@ -284,16 +284,31 @@ public interface ISubjectRepository
}
public interface ICompetencyDomainRepository
{
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel);
// Über alle Klassenstufen eines Fachs hinweg - für einen Überblick über den gesamten Katalog
// eines Fachs (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> GetBySubject(Guid subjectId);
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel, Guid versionId);
// Über alle Klassenstufen einer Version eines Fachs hinweg - für einen Überblick über den
// 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);
CompetencyDomain? GetById(Guid id);
void Save(CompetencyDomain domain);
void Delete(Guid id);
void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel);
void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains);
void DeleteBySubjectGradeAndVersion(Guid subjectId, int gradeLevel, Guid versionId);
void ReplaceForSubjectGradeAndVersion(Guid subjectId, int gradeLevel, Guid versionId, List<CompetencyDomain> domains);
}
public interface ICompetencyCatalogVersionRepository
{
List<CompetencyCatalogVersion> GetBySubject(Guid subjectId);
CompetencyCatalogVersion? GetById(Guid id);
/// <summary>Liefert die Standardversion des Fachs und legt sie an ("Bestehender Kompetenzkatalog"),
/// falls noch keine existiert - so brauchen Aufrufer, die nur einen Guid für die Domain-Suche
/// benötigen, keinen eigenen Anlegen-oder-Lesen-Ablauf.</summary>
CompetencyCatalogVersion GetOrCreateDefault(Guid subjectId);
void Save(CompetencyCatalogVersion version);
/// <summary>Blockiert (InvalidOperationException), wenn noch ein CompetencyDomain oder eine
/// LearningGroup diese Version referenziert, oder wenn sie die Standardversion ihres Fachs ist
/// (erst eine andere Version des Fachs als Standard setzen).</summary>
void Delete(Guid id);
}
public interface IShorthandCodeRepository
{
+21
View File
@@ -5,6 +5,11 @@ public class CompetencyDomain
public Guid Id { get; set; } = Guid.NewGuid();
public Guid SubjectId { get; set; }
public int GradeLevel { get; set; }
/// <summary>Bildungsplan-Version dieses Bereichs (<see cref="CompetencyCatalogVersion"/>) - ein
/// Fach kann mehrere parallel gültige Versionen haben (altes/neues Bildungsplan, während dieser
/// klassenstufenweise hochwächst). Wird von der Migration für bestehende Datensätze auf die
/// jeweilige Standardversion des Fachs gesetzt.</summary>
public Guid VersionId { get; set; }
public string Name { get; set; } = "";
public string Code { get; set; } = "";
public int SortOrder { get; set; }
@@ -12,6 +17,22 @@ public class CompetencyDomain
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>Eine Bildungsplan-Version eines Fachs (z.B. "Bildungsplan 2016" vs. "Bildungsplan 2026").
/// Ermöglicht es, während ein neuer Bildungsplan klassenstufenweise hochwächst, alten und neuen
/// Kompetenzkatalog parallel zu pflegen - <see cref="LearningGroup.CompetencyCatalogVersionId"/>
/// wählt je Kurs/Klasse, welche Version gilt. Je Fach ist genau eine Version <see cref="IsDefault"/>
/// (Kurse ohne explizite Wahl nutzen sie, siehe Doc-Kommentar auf <see cref="LearningGroup"/>).</summary>
public class CompetencyCatalogVersion
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid SubjectId { get; set; }
public string Name { get; set; } = "";
public bool IsDefault { get; set; }
public int SortOrder { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class CompetencyItem
{
public Guid Id { get; set; } = Guid.NewGuid();
+8
View File
@@ -8,6 +8,14 @@ public class LearningGroup
public Guid? SubjectId { get; set; }
public string SchoolYear { get; set; } = "";
public int GradeLevel { get; set; }
/// <summary>
/// Gewählte Bildungsplan-Version (<see cref="CompetencyCatalogVersion"/>) für den Kompetenzkatalog
/// dieses Kurses/dieser Klasse. Null bedeutet bewusst nicht "keine Version", sondern "Standardversion
/// des Fachs" (<see cref="CompetencyCatalogVersion.IsDefault"/>) - analog dazu, dass das Schuljahr
/// nicht auf der Mitgliedschaft dupliziert wird, sondern von der Gruppe geerbt wird. So brauchen
/// bestehende Kurse ohne neuen Bildungsplan keine Migration.
/// </summary>
public Guid? CompetencyCatalogVersionId { get; set; }
public GradingSystem GradingSystem { get; set; }
public int? HoursPerWeek { get; set; }
public bool IsActive { get; set; } = true;
@@ -15,6 +15,7 @@ public sealed class CompetencyCatalogImportPreview
public required Guid SubjectId { get; init; }
public required string SubjectName { get; init; }
public required int GradeLevel { get; init; }
public required Guid VersionId { get; init; }
public required List<CompetencyDomain> ImportedDomains { get; init; }
public required string ExistingFingerprint { get; init; }
public List<string> Warnings { get; init; } = [];
@@ -28,7 +29,7 @@ public sealed class CompetencyCatalogImportPreview
public sealed class CompetencyCatalogImportService(ICompetencyDomainRepository repository)
{
public CompetencyCatalogImportPreview Analyze(
string json, Guid subjectId, string subjectName, int gradeLevel)
string json, Guid subjectId, string subjectName, int gradeLevel, Guid versionId)
{
CatalogDto dto;
try
@@ -100,7 +101,7 @@ public sealed class CompetencyCatalogImportService(ICompetencyDomainRepository r
if (dto.GradeLevel is > 0 and <= 13 && dto.GradeLevel != gradeLevel)
warnings.Add($"Die Datei nennt Klassenstufe {dto.GradeLevel}, ausgewählt ist Klassenstufe {gradeLevel}.");
var existing = repository.GetBySubjectAndGrade(subjectId, gradeLevel);
var existing = repository.GetBySubjectAndGrade(subjectId, gradeLevel, versionId);
var conflicts = new List<CompetencyCatalogImportConflict>();
var newDomains = 0;
var newItems = 0;
@@ -147,6 +148,7 @@ public sealed class CompetencyCatalogImportService(ICompetencyDomainRepository r
SubjectId = subjectId,
SubjectName = subjectName,
GradeLevel = gradeLevel,
VersionId = versionId,
ImportedDomains = imported,
ExistingFingerprint = Fingerprint(existing),
Warnings = warnings,
@@ -160,7 +162,7 @@ public sealed class CompetencyCatalogImportService(ICompetencyDomainRepository r
public void Apply(CompetencyCatalogImportPreview preview, CompetencyCatalogImportMode mode,
IReadOnlySet<string>? useImportedConflicts = null)
{
var current = repository.GetBySubjectAndGrade(preview.SubjectId, preview.GradeLevel);
var current = repository.GetBySubjectAndGrade(preview.SubjectId, preview.GradeLevel, preview.VersionId);
if (Fingerprint(current) != preview.ExistingFingerprint)
throw new InvalidOperationException(
"Der Kompetenzkatalog wurde seit der Vorschau verändert. Bitte öffne den Import erneut.");
@@ -169,7 +171,7 @@ public sealed class CompetencyCatalogImportService(ICompetencyDomainRepository r
? preview.ImportedDomains.Select((d, index) => CloneAsNew(d, preview.SubjectId, preview.GradeLevel, index)).ToList()
: Merge(current, preview, useImportedConflicts ?? new HashSet<string>());
repository.ReplaceForSubjectAndGrade(preview.SubjectId, preview.GradeLevel, result);
repository.ReplaceForSubjectGradeAndVersion(preview.SubjectId, preview.GradeLevel, preview.VersionId, result);
}
private static List<CompetencyDomain> Merge(List<CompetencyDomain> existing,
@@ -0,0 +1,17 @@
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Core.Services;
/// <summary>Löst die für einen Kurs/eine Klasse geltende Bildungsplan-Version auf: die explizit am
/// Kurs gewählte Version (<see cref="Models.LearningGroup.CompetencyCatalogVersionId"/>), falls sie
/// noch existiert, sonst die Standardversion des Fachs (siehe Doc-Kommentar auf dem Feld). Ohne Fach
/// gibt es keine Version (<see cref="Guid.Empty"/>).</summary>
public static class CompetencyCatalogVersionResolver
{
public static Guid Resolve(ICompetencyCatalogVersionRepository versions, Guid? subjectId, Guid? groupVersionId)
{
if (subjectId is not Guid sid) return Guid.Empty;
if (groupVersionId is Guid vid && versions.GetById(vid) is { } explicitVersion) return explicitVersion.Id;
return versions.GetOrCreateDefault(sid).Id;
}
}
@@ -123,7 +123,7 @@ public sealed class ChangeHookCascadeTests
}
[Fact]
public void CompetencyDomainRepository_ReplaceForSubjectAndGrade_LoestDeleteFuerAlteUndSaveFuerNeueAus()
public void CompetencyDomainRepository_ReplaceForSubjectGradeAndVersion_LoestDeleteFuerAlteUndSaveFuerNeueAus()
{
using var db = NewInMemoryContext();
var repo = new CompetencyDomainRepository(db);
@@ -132,7 +132,7 @@ public sealed class ChangeHookCascadeTests
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.ReplaceForSubjectAndGrade(subjectId, 8,
repo.ReplaceForSubjectGradeAndVersion(subjectId, 8, Guid.Empty,
[
new CompetencyDomain { Name = "Neu 1" },
new CompetencyDomain { Name = "Neu 2" },
@@ -145,7 +145,7 @@ public sealed class ChangeHookCascadeTests
}
[Fact]
public void CompetencyDomainRepository_DeleteBySubjectAndGrade_LoestEinEreignisProDomaenAus()
public void CompetencyDomainRepository_DeleteBySubjectGradeAndVersion_LoestEinEreignisProDomaenAus()
{
using var db = NewInMemoryContext();
var repo = new CompetencyDomainRepository(db);
@@ -155,7 +155,7 @@ public sealed class ChangeHookCascadeTests
var calls = new List<(string EntityType, string Operation)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op));
repo.DeleteBySubjectAndGrade(subjectId, 8);
repo.DeleteBySubjectGradeAndVersion(subjectId, 8, Guid.Empty);
Assert.Equal(2, calls.Count);
Assert.All(calls, c => Assert.Equal((nameof(CompetencyDomain), "Delete"), c));
@@ -121,6 +121,14 @@ public sealed class ChangeHookMatrixTests
return (subject.Id, () => repo.Delete(subject.Id));
});
yield return Case("CompetencyCatalogVersion", nameof(CompetencyCatalogVersion), db =>
{
var repo = new CompetencyCatalogVersionRepository(db);
var version = new CompetencyCatalogVersion { SubjectId = Guid.NewGuid(), Name = "Bildungsplan 2026" };
repo.Save(version);
return (version.Id, () => repo.Delete(version.Id));
});
yield return Case("ShorthandCode", nameof(ShorthandCode), db =>
{
var repo = new ShorthandCodeRepository(db);
+47 -2
View File
@@ -121,7 +121,7 @@ public sealed class LiteDbContextTests
using var temp = new TempDatabase();
using var context = new LiteDbContext(temp.Path);
Assert.Equal(4, context.SchemaVersion);
Assert.Equal(5, context.SchemaVersion);
}
[Fact]
@@ -132,7 +132,52 @@ public sealed class LiteDbContextTests
using var second = new LiteDbContext(temp.Path);
Assert.Equal(4, second.SchemaVersion);
Assert.Equal(5, second.SchemaVersion);
}
[Fact]
public void MigrateCompetencyCatalogVersions_ErzeugtStandardversionJeFachUndVerknuepftBestehendeBereiche()
{
using var temp = new TempDatabase();
var subjectAId = Guid.NewGuid();
var subjectBId = Guid.NewGuid();
var domainA1Id = Guid.NewGuid();
var domainA2Id = Guid.NewGuid();
var domainBId = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
var domains = legacy.GetCollection<BsonDocument>("competency_domains");
domains.Insert(new BsonDocument
{
["_id"] = domainA1Id, [nameof(CompetencyDomain.SubjectId)] = subjectAId,
[nameof(CompetencyDomain.GradeLevel)] = 8, [nameof(CompetencyDomain.Name)] = "Bereich A1",
});
domains.Insert(new BsonDocument
{
["_id"] = domainA2Id, [nameof(CompetencyDomain.SubjectId)] = subjectAId,
[nameof(CompetencyDomain.GradeLevel)] = 9, [nameof(CompetencyDomain.Name)] = "Bereich A2",
});
domains.Insert(new BsonDocument
{
["_id"] = domainBId, [nameof(CompetencyDomain.SubjectId)] = subjectBId,
[nameof(CompetencyDomain.GradeLevel)] = 8, [nameof(CompetencyDomain.Name)] = "Bereich B",
});
}
using var context = new LiteDbContext(temp.Path);
var versions = context.CompetencyCatalogVersions.FindAll().ToList();
var versionA = Assert.Single(versions, v => v.SubjectId == subjectAId);
var versionB = Assert.Single(versions, v => v.SubjectId == subjectBId);
Assert.True(versionA.IsDefault);
Assert.True(versionB.IsDefault);
Assert.Equal("Bestehender Kompetenzkatalog", versionA.Name);
var domainsAfter = context.CompetencyDomains.FindAll().ToDictionary(d => d.Id);
Assert.Equal(versionA.Id, domainsAfter[domainA1Id].VersionId);
Assert.Equal(versionA.Id, domainsAfter[domainA2Id].VersionId);
Assert.Equal(versionB.Id, domainsAfter[domainBId].VersionId);
}
[Fact]
+89
View File
@@ -1249,4 +1249,93 @@ public sealed class RepositoryTests
Assert.Equal(studentId, item.StudentId);
Assert.True(item.IsDone);
}
// ── CompetencyCatalogVersionRepository ──────────────────────────────────────────────────
[Fact]
public void CompetencyCatalogVersionRepository_GetOrCreateDefault_LegtGenauEinmalAn()
{
using var db = NewInMemoryContext();
var repo = new CompetencyCatalogVersionRepository(db);
var subjectId = Guid.NewGuid();
var first = repo.GetOrCreateDefault(subjectId);
var second = repo.GetOrCreateDefault(subjectId);
Assert.Equal(first.Id, second.Id);
Assert.True(first.IsDefault);
Assert.Single(repo.GetBySubject(subjectId));
}
[Fact]
public void CompetencyCatalogVersionRepository_Save_NimmtVorherigeStandardversionZurueck()
{
using var db = NewInMemoryContext();
var repo = new CompetencyCatalogVersionRepository(db);
var subjectId = Guid.NewGuid();
var alt = repo.GetOrCreateDefault(subjectId);
var neu = new CompetencyCatalogVersion { SubjectId = subjectId, Name = "Bildungsplan 2026", IsDefault = true };
repo.Save(neu);
Assert.False(repo.GetById(alt.Id)!.IsDefault);
Assert.True(repo.GetById(neu.Id)!.IsDefault);
}
[Fact]
public void CompetencyCatalogVersionRepository_Delete_BlockiertStandardversion()
{
using var db = NewInMemoryContext();
var repo = new CompetencyCatalogVersionRepository(db);
var version = repo.GetOrCreateDefault(Guid.NewGuid());
Assert.Throws<InvalidOperationException>(() => repo.Delete(version.Id));
}
[Fact]
public void CompetencyCatalogVersionRepository_Delete_BlockiertBeiReferenzierendemKompetenzbereich()
{
using var db = NewInMemoryContext();
var versionRepo = new CompetencyCatalogVersionRepository(db);
var domainRepo = new CompetencyDomainRepository(db);
var subjectId = Guid.NewGuid();
var version = new CompetencyCatalogVersion { SubjectId = subjectId, Name = "Bildungsplan 2026" };
versionRepo.Save(version);
domainRepo.Save(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 5, VersionId = version.Id, Name = "Bereich" });
Assert.Throws<InvalidOperationException>(() => versionRepo.Delete(version.Id));
}
[Fact]
public void CompetencyCatalogVersionRepository_Delete_BlockiertBeiReferenzierenderGruppe()
{
using var db = NewInMemoryContext();
var versionRepo = new CompetencyCatalogVersionRepository(db);
var groupRepo = new GroupRepository(db);
var subject = new Subject { Name = "Chemie" };
new SubjectRepository(db).Save(subject);
var version = new CompetencyCatalogVersion { SubjectId = subject.Id, Name = "Bildungsplan 2026" };
versionRepo.Save(version);
groupRepo.Save(new LearningGroup
{
Name = "9a", SchoolYear = "2025/26", SubjectId = subject.Id, CompetencyCatalogVersionId = version.Id,
});
Assert.Throws<InvalidOperationException>(() => versionRepo.Delete(version.Id));
}
[Fact]
public void CompetencyCatalogVersionRepository_Delete_ErlaubtUnbenutzteNichtStandardversion()
{
using var db = NewInMemoryContext();
var repo = new CompetencyCatalogVersionRepository(db);
var subjectId = Guid.NewGuid();
repo.GetOrCreateDefault(subjectId);
var extra = new CompetencyCatalogVersion { SubjectId = subjectId, Name = "Entwurf" };
repo.Save(extra);
repo.Delete(extra.Id);
Assert.Null(repo.GetById(extra.Id));
}
}
+34 -1
View File
@@ -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 = 4;
private const int CurrentSchemaVersion = 5;
private readonly LiteDatabase _db;
@@ -61,6 +61,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<ParticipationSection> ParticipationSections => _db.GetCollection<ParticipationSection>("participation_sections");
public ILiteCollection<Subject> Subjects => _db.GetCollection<Subject>("subjects");
public ILiteCollection<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains");
public ILiteCollection<CompetencyCatalogVersion> CompetencyCatalogVersions => _db.GetCollection<CompetencyCatalogVersion>("competency_catalog_versions");
public ILiteCollection<ShorthandCode> ShorthandCodes => _db.GetCollection<ShorthandCode>("shorthand_codes");
public ILiteCollection<AlternativeLessonPath> AlternativeLessonPaths => _db.GetCollection<AlternativeLessonPath>("alternative_lesson_paths");
public ILiteCollection<TimetableSlot> TimetableSlots => _db.GetCollection<TimetableSlot>("timetable_slots");
@@ -262,6 +263,11 @@ public class LiteDbContext : IDisposable
MigrateLessonAlternativePaths();
version = 4;
}
if (version < 5)
{
MigrateCompetencyCatalogVersions();
version = 5;
}
WriteSchemaVersion(version);
}
@@ -485,6 +491,31 @@ public class LiteDbContext : IDisposable
}
}
/// <summary>Backfill für <see cref="CompetencyDomain.VersionId"/>: bestehende Bereiche (vor
/// Einführung der Bildungsplan-Versionierung, also ohne VersionId) bekommen je Fach genau eine
/// neu angelegte Standardversion ("Bestehender Kompetenzkatalog"). LearningGroup.CompetencyCatalogVersionId
/// bleibt bewusst null (= Standardversion) - siehe Doc-Kommentar auf dem Feld.</summary>
private void MigrateCompetencyCatalogVersions()
{
var defaultVersionBySubject = new Dictionary<Guid, Guid>();
foreach (var domain in CompetencyDomains.FindAll().ToList())
{
if (domain.VersionId != Guid.Empty) continue;
if (!defaultVersionBySubject.TryGetValue(domain.SubjectId, out var versionId))
{
var version = new CompetencyCatalogVersion
{
SubjectId = domain.SubjectId, Name = "Bestehender Kompetenzkatalog", IsDefault = true,
};
CompetencyCatalogVersions.Upsert(version);
versionId = version.Id;
defaultVersionBySubject[domain.SubjectId] = versionId;
}
domain.VersionId = versionId;
CompetencyDomains.Update(domain);
}
}
private void EnsureIndexes()
{
Students.EnsureIndex(x => x.LastName);
@@ -530,6 +561,8 @@ public class LiteDbContext : IDisposable
Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
CompetencyDomains.EnsureIndex(x => x.SubjectId);
CompetencyDomains.EnsureIndex(x => x.GradeLevel);
CompetencyDomains.EnsureIndex(x => x.VersionId);
CompetencyCatalogVersions.EnsureIndex(x => x.SubjectId);
ShorthandCodes.EnsureIndex("ux_shorthand_code", BsonExpression.Create("LOWER(TRIM($.Code))"), unique: true);
AlternativeLessonPaths.EnsureIndex("ux_alt_lesson_path_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
TimetableSlots.EnsureIndex(x => x.GroupId);
+63 -10
View File
@@ -670,8 +670,9 @@ public class SubjectRepository(LiteDbContext db) : ISubjectRepository
}
public void Delete(Guid id)
{
if (db.Groups.Exists(g => g.SubjectId == id) || db.CompetencyDomains.Exists(d => d.SubjectId == id))
throw new InvalidOperationException("Das Fach wird noch von einer Lerngruppe oder einem Kompetenzkatalog verwendet.");
if (db.Groups.Exists(g => g.SubjectId == id) || db.CompetencyDomains.Exists(d => d.SubjectId == id)
|| db.CompetencyCatalogVersions.Exists(v => v.SubjectId == id))
throw new InvalidOperationException("Das Fach wird noch von einer Lerngruppe, einem Kompetenzkatalog oder einer Bildungsplan-Version verwendet.");
db.Subjects.Delete(id);
db.OnChange?.Invoke(nameof(Subject), id.ToString(), "Delete", null);
}
@@ -916,14 +917,14 @@ public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRep
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel, Guid versionId) =>
db.CompetencyDomains
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel && d.VersionId == versionId)
.OrderBy(d => d.SortOrder)
.ToList();
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
public List<CompetencyDomain> GetBySubjectAndVersion(Guid subjectId, Guid versionId) =>
db.CompetencyDomains
.Find(d => d.SubjectId == subjectId)
.Find(d => d.SubjectId == subjectId && d.VersionId == versionId)
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder)
.ToList();
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
@@ -938,22 +939,22 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
db.CompetencyDomains.Delete(id);
db.OnChange?.Invoke(nameof(CompetencyDomain), id.ToString(), "Delete", null);
}
public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel)
public void DeleteBySubjectGradeAndVersion(Guid subjectId, int gradeLevel, Guid versionId)
{
foreach (var d in db.CompetencyDomains
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel && d.VersionId == versionId)
.ToList())
{
db.CompetencyDomains.Delete(d.Id);
db.OnChange?.Invoke(nameof(CompetencyDomain), d.Id.ToString(), "Delete", null);
}
}
public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains)
public void ReplaceForSubjectGradeAndVersion(Guid subjectId, int gradeLevel, Guid versionId, List<CompetencyDomain> domains)
{
db.ExecuteInTransaction(() =>
{
foreach (var domain in db.CompetencyDomains
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList())
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel && d.VersionId == versionId).ToList())
{
db.CompetencyDomains.Delete(domain.Id);
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Delete", null);
@@ -963,6 +964,7 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
{
domain.SubjectId = subjectId;
domain.GradeLevel = gradeLevel;
domain.VersionId = versionId;
domain.UpdatedAt = now;
db.CompetencyDomains.Upsert(domain);
db.OnChange?.Invoke(nameof(CompetencyDomain), domain.Id.ToString(), "Save", domain);
@@ -971,6 +973,57 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
}
}
public class CompetencyCatalogVersionRepository(LiteDbContext db) : ICompetencyCatalogVersionRepository
{
public List<CompetencyCatalogVersion> GetBySubject(Guid subjectId) =>
db.CompetencyCatalogVersions
.Find(v => v.SubjectId == subjectId)
.OrderBy(v => v.SortOrder)
.ToList();
public CompetencyCatalogVersion? GetById(Guid id) => db.CompetencyCatalogVersions.FindById(id);
public CompetencyCatalogVersion GetOrCreateDefault(Guid subjectId)
{
var existing = GetBySubject(subjectId).FirstOrDefault(v => v.IsDefault);
if (existing is not null) return existing;
var version = new CompetencyCatalogVersion
{
SubjectId = subjectId, Name = "Bestehender Kompetenzkatalog", IsDefault = true,
};
Save(version);
return version;
}
public void Save(CompetencyCatalogVersion v)
{
v.Name = v.Name.Trim();
if (v.Name.Length == 0) throw new ArgumentException("Der Name der Version darf nicht leer sein.");
if (v.IsDefault)
{
// Höchstens eine Standardversion je Fach - beim Setzen alle anderen des Fachs zurücknehmen.
foreach (var other in db.CompetencyCatalogVersions.Find(o => o.SubjectId == v.SubjectId && o.Id != v.Id && o.IsDefault).ToList())
{
other.IsDefault = false;
other.UpdatedAt = DateTime.UtcNow;
db.CompetencyCatalogVersions.Upsert(other);
db.OnChange?.Invoke(nameof(CompetencyCatalogVersion), other.Id.ToString(), "Save", other);
}
}
v.UpdatedAt = DateTime.UtcNow;
db.CompetencyCatalogVersions.Upsert(v);
db.OnChange?.Invoke(nameof(CompetencyCatalogVersion), v.Id.ToString(), "Save", v);
}
public void Delete(Guid id)
{
var version = db.CompetencyCatalogVersions.FindById(id);
if (version is null) return;
if (version.IsDefault)
throw new InvalidOperationException("Die Standardversion eines Fachs kann nicht gelöscht werden. Erst eine andere Version als Standard setzen.");
if (db.CompetencyDomains.Exists(d => d.VersionId == id) || db.Groups.Exists(g => g.CompetencyCatalogVersionId == id))
throw new InvalidOperationException("Die Version wird noch von einem Kompetenzkatalog oder einem Kurs verwendet.");
db.CompetencyCatalogVersions.Delete(id);
db.OnChange?.Invoke(nameof(CompetencyCatalogVersion), id.ToString(), "Delete", null);
}
}
/// <summary>Papierkorb (14.3) — bewusst nicht sync-fähig: TrashedItem wird nie über
/// db.OnChange gemeldet, bleibt also rein lokal auf dem Gerät, auf dem gelöscht wurde. Ein
/// "Fehlklick sofort rückgängig machen"-Werkzeug, kein geräteübergreifendes Archiv.</summary>
@@ -15,8 +15,10 @@ namespace LehrerApp.Desktop.Tests;
public sealed class AiPlanningServiceTests
{
private static AiPlanningService Build(FakeLessons lessons, FakeGroups groups, FakeSubjects subjects,
FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) =>
new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths);
FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths,
FakeCompetencyCatalogVersions? competencyVersions = null) =>
new(new HttpClient(), lessons, groups, subjects, competencyDomains,
competencyVersions ?? new FakeCompetencyCatalogVersions(), altPaths);
[Fact]
public void ParsePlanningLessons_AkzeptiertNormaleAntwortUndDeutschesDatum()
@@ -90,7 +92,7 @@ public sealed class AiPlanningServiceTests
BaseAddress = new Uri("https://example.invalid/"),
};
var service = new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]));
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
service.RequestPlanAsync(new Unit(), "", "token"));
@@ -189,7 +191,7 @@ public sealed class AiPlanningServiceTests
BaseAddress = new Uri("https://example.invalid/"),
};
return new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]));
}
private sealed class StaticResponseHandler(HttpStatusCode status, string body) : HttpMessageHandler
@@ -235,14 +237,16 @@ public sealed class AiPlanningServiceTests
var unit = new Unit { GroupId = group.Id, Title = "T" };
var domains = new FakeCompetencyDomains();
var versions = new FakeCompetencyCatalogVersions();
var version = versions.GetOrCreateDefault(subject.Id);
domains.Add(new CompetencyDomain
{
SubjectId = subject.Id, GradeLevel = 9, Name = "Chemische Reaktionen",
SubjectId = subject.Id, GradeLevel = 9, VersionId = version.Id, Name = "Chemische Reaktionen",
Items = [new CompetencyItem { Code = "C1", Description = "Redoxreaktionen erklären" }],
});
var service = Build(new FakeLessons(), new FakeGroups([group]), new FakeSubjects([subject]),
domains, new FakeAlternativeLessonPaths([]));
domains, new FakeAlternativeLessonPaths([]), versions);
var context = service.BuildContext(unit, "");
@@ -26,16 +26,19 @@ public sealed class CompetencyOverviewViewModelTests
};
var results = new FakeResults();
results.Add(new ExamResult { ExamId = exam.Id, StudentId = student.Id, Points = [5] });
var versions = new FakeCompetencyCatalogVersions();
var version = versions.GetOrCreateDefault(subjectId);
var catalog = new FakeCompetencyDomains();
catalog.Add(new CompetencyDomain
{
SubjectId = subjectId,
GradeLevel = 8,
VersionId = version.Id,
Name = "Bereich",
Items = [new CompetencyItem { Code = "K1", Description = "Kompetenz" }],
});
var vm = new CompetencyOverviewTabViewModel(units, new FakeExams([exam]), results,
catalog, new FakeStudents([student]), new CompetencyAnalysisService());
catalog, versions, new FakeStudents([student]), new CompetencyAnalysisService());
vm.Initialize(group);
+42 -9
View File
@@ -30,7 +30,7 @@ public static class TestSupport
/// solange AiSettingsService.IsLoggedIn false ist.
public static AiPlanningService BuildAiPlanningService() => new(
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]));
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static McpSettingsService BuildMcpSettingsService()
@@ -391,24 +391,57 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository
{
private readonly List<CompetencyDomain> _all = [];
public void Add(CompetencyDomain d) => _all.Add(d);
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel, Guid versionId) =>
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel && d.VersionId == versionId)
.OrderBy(d => d.SortOrder).ToList();
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
_all.Where(d => d.SubjectId == subjectId)
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 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);
public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
_all.RemoveAll(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel);
public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains)
public void DeleteBySubjectGradeAndVersion(Guid subjectId, int gradeLevel, Guid versionId) =>
_all.RemoveAll(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel && d.VersionId == versionId);
public void ReplaceForSubjectGradeAndVersion(Guid subjectId, int gradeLevel, Guid versionId, List<CompetencyDomain> domains)
{
DeleteBySubjectAndGrade(subjectId, gradeLevel);
DeleteBySubjectGradeAndVersion(subjectId, gradeLevel, versionId);
foreach (var d in domains) { d.SubjectId = subjectId; d.GradeLevel = gradeLevel; d.VersionId = versionId; }
_all.AddRange(domains);
}
}
public class FakeCompetencyCatalogVersions : ICompetencyCatalogVersionRepository
{
private readonly List<CompetencyCatalogVersion> _all = [];
public void Add(CompetencyCatalogVersion v) => _all.Add(v);
public List<CompetencyCatalogVersion> GetBySubject(Guid subjectId) =>
_all.Where(v => v.SubjectId == subjectId).OrderBy(v => v.SortOrder).ToList();
public CompetencyCatalogVersion? GetById(Guid id) => _all.FirstOrDefault(v => v.Id == id);
public CompetencyCatalogVersion GetOrCreateDefault(Guid subjectId)
{
var existing = GetBySubject(subjectId).FirstOrDefault(v => v.IsDefault);
if (existing is not null) return existing;
var version = new CompetencyCatalogVersion { SubjectId = subjectId, Name = "Bestehender Kompetenzkatalog", IsDefault = true };
Save(version);
return version;
}
public void Save(CompetencyCatalogVersion version)
{
if (version.IsDefault)
foreach (var other in _all.Where(v => v.SubjectId == version.SubjectId && v.Id != version.Id)) other.IsDefault = false;
_all.RemoveAll(v => v.Id == version.Id);
_all.Add(version);
}
public void Delete(Guid id)
{
var version = _all.FirstOrDefault(v => v.Id == id);
if (version is null) return;
if (version.IsDefault)
throw new InvalidOperationException("Die Standardversion eines Fachs kann nicht gelöscht werden. Erst eine andere Version als Standard setzen.");
_all.RemoveAll(v => v.Id == id);
}
}
public class FakeShorthandCodes(List<ShorthandCode> all) : IShorthandCodeRepository
{
public List<ShorthandCode> GetAll() => all;
@@ -21,12 +21,12 @@ public sealed class GroupDetailViewModelTests
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
students, memberships, groups, new FakeCompetencyDomains()),
students, memberships, groups, new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects,
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), new FakeSessions([])),
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), TestSupport.BuildAiSettingsService(), new FakeSessions([])),
new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(),
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), students, new CompetencyAnalysisService()),
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects()),
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups));
@@ -254,7 +254,7 @@ public sealed class HazardAssessmentWizardViewModelTests
aiSettings.SetEnabled(true);
aiSettings.SetCredentialsAndToken("sebastian", "token-abc");
var ai = new AiPlanningService(new HttpClient(), new FakeLessons(), new FakeGroups([]),
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]));
var unit = new Unit { Title = "Redoxreaktionen" };
var lesson = new Lesson { Topic = "Natrium in Wasser" };
+118 -20
View File
@@ -15,7 +15,8 @@ public sealed class McpToolsTests
Assert.Equal(
new[]
{
"download_lesson_attachment", "get_competency_catalog", "get_exams", "get_grades",
"download_lesson_attachment", "get_competency_catalog", "get_competency_plan_versions",
"get_exams", "get_grades",
"get_groups", "get_lesson_plans", "get_named_untis_absence_pattern", "get_schedule",
"get_students", "get_subjects", "get_time_entries", "get_untis_absence_rows",
"get_untis_hub_status", "list_letter_templates", "render_letter",
@@ -30,10 +31,12 @@ public sealed class McpToolsTests
new[]
{
"add_competency_item", "add_lesson_attachment", "add_lesson_competency", "add_lesson_phase",
"apply_untis_absence_status", "create_competency_domain", "create_grade_entry", "create_lesson",
"apply_untis_absence_status", "create_competency_domain", "create_competency_plan_version",
"create_grade_entry", "create_lesson",
"create_subject", "create_time_entry", "create_unit", "move_lesson", "remove_competency_item",
"remove_lesson_competency", "remove_lesson_phase", "update_competency_domain",
"update_competency_item", "update_lesson", "update_lesson_phase", "update_student_group_assignment",
"update_competency_item", "update_competency_plan_version", "update_group_competency_version",
"update_lesson", "update_lesson_phase", "update_student_group_assignment",
"update_subject", "update_unit",
},
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
@@ -43,7 +46,7 @@ public sealed class McpToolsTests
public void AllowedDestructiveWriteTools_EnthaeltDeleteLessonSubjectUndCompetencyDomain()
{
Assert.Equal(
new[] { "delete_competency_domain", "delete_lesson", "delete_subject" },
new[] { "delete_competency_domain", "delete_competency_plan_version", "delete_lesson", "delete_subject" },
McpToolScope.AllowedDestructiveWriteTools.OrderBy(n => n, StringComparer.Ordinal));
}
@@ -68,7 +71,7 @@ public sealed class McpToolsTests
{
Name = "9a", Type = GroupType.Class, SchoolYear = "2025/26", GradeLevel = 9, IsActive = true,
};
var tool = new GroupTools(new FakeGroups([group]));
var tool = new GroupTools(new FakeGroups([group]), new FakeCompetencyCatalogVersions(), new FakeMcpConfirmation());
var result = Assert.Single(tool.GetGroups());
@@ -735,7 +738,7 @@ public sealed class McpToolsTests
public void GetSubjects_LiefertAlleFaecher()
{
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
var tool = new CompetencyTools(new FakeSubjects([subject]), new FakeCompetencyDomains(), new FakeMcpConfirmation());
var tool = new CompetencyTools(new FakeSubjects([subject]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeMcpConfirmation());
var dto = Assert.Single(tool.GetSubjects());
@@ -746,7 +749,7 @@ public sealed class McpToolsTests
public async Task CreateSubject_NutzerBestaetigt_SpeichertFach()
{
var subjects = new FakeSubjects([]);
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeMcpConfirmation());
var result = await tool.CreateSubject("Mathematik", "Ma");
@@ -759,7 +762,7 @@ public sealed class McpToolsTests
public async Task CreateSubject_NameBereitsVergeben_LiefertFehler()
{
var subjects = new FakeSubjects([new Subject { Name = "Mathematik" }]);
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeMcpConfirmation());
var result = await tool.CreateSubject("Mathematik");
@@ -772,7 +775,7 @@ public sealed class McpToolsTests
{
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
var subjects = new FakeSubjects([subject]);
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeMcpConfirmation());
var result = await tool.UpdateSubject(subject.Id, shortName: "M");
@@ -787,7 +790,7 @@ public sealed class McpToolsTests
{
var subject = new Subject { Name = "Mathematik" };
var subjects = new FakeSubjects([subject]);
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeMcpConfirmation());
var result = await tool.DeleteSubject(subject.Id);
@@ -799,7 +802,7 @@ public sealed class McpToolsTests
public async Task DeleteSubject_UnbekannteId_LiefertFehlerOhneNachfrage()
{
var confirmation = new FakeMcpConfirmation();
var tool = new CompetencyTools(new FakeSubjects([]), new FakeCompetencyDomains(), confirmation);
var tool = new CompetencyTools(new FakeSubjects([]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), confirmation);
var result = await tool.DeleteSubject(Guid.NewGuid());
@@ -812,9 +815,11 @@ public sealed class McpToolsTests
{
var subjectId = Guid.NewGuid();
var domains = new FakeCompetencyDomains();
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 5, Name = "Zahlen" });
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 9, Name = "Funktionen" });
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var versions = new FakeCompetencyCatalogVersions();
var version = versions.GetOrCreateDefault(subjectId);
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 5, VersionId = version.Id, Name = "Zahlen" });
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 9, VersionId = version.Id, Name = "Funktionen" });
var tool = new CompetencyTools(new FakeSubjects([]), domains, versions, new FakeMcpConfirmation());
var result = tool.GetCompetencyCatalog(subjectId);
@@ -826,12 +831,14 @@ public sealed class McpToolsTests
{
var subject = new Subject { Name = "Mathematik" };
var domains = new FakeCompetencyDomains();
var tool = new CompetencyTools(new FakeSubjects([subject]), domains, new FakeMcpConfirmation());
var versions = new FakeCompetencyCatalogVersions();
var tool = new CompetencyTools(new FakeSubjects([subject]), domains, versions, new FakeMcpConfirmation());
var result = await tool.CreateCompetencyDomain(subject.Id, 9, "Funktionen");
Assert.True(result.Applied);
var domain = Assert.Single(domains.GetBySubjectAndGrade(subject.Id, 9));
var defaultVersionId = versions.GetOrCreateDefault(subject.Id).Id;
var domain = Assert.Single(domains.GetBySubjectAndGrade(subject.Id, 9, defaultVersionId));
Assert.Equal("Funktionen", domain.Name);
}
@@ -842,7 +849,7 @@ public sealed class McpToolsTests
domain.Items.Add(new CompetencyItem { Code = "M.9.1", Description = "..." });
var domains = new FakeCompetencyDomains();
domains.Add(domain);
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeCompetencyCatalogVersions(), new FakeMcpConfirmation());
var result = await tool.DeleteCompetencyDomain(domain.Id);
@@ -856,7 +863,7 @@ public sealed class McpToolsTests
var domain = new CompetencyDomain { Name = "Funktionen" };
var domains = new FakeCompetencyDomains();
domains.Add(domain);
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeCompetencyCatalogVersions(), new FakeMcpConfirmation());
var result = await tool.AddCompetencyItem(domain.Id, "M.9.1", "lineare Funktionen erkennen");
@@ -873,7 +880,7 @@ public sealed class McpToolsTests
domain.Items.Add(item);
var domains = new FakeCompetencyDomains();
domains.Add(domain);
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeCompetencyCatalogVersions(), new FakeMcpConfirmation());
var result = await tool.UpdateCompetencyItem(domain.Id, item.Id, description: "neu");
@@ -891,11 +898,102 @@ public sealed class McpToolsTests
domain.Items.Add(item);
var domains = new FakeCompetencyDomains();
domains.Add(domain);
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeCompetencyCatalogVersions(), new FakeMcpConfirmation());
var result = await tool.RemoveCompetencyItem(domain.Id, item.Id);
Assert.True(result.Applied);
Assert.Empty(domains.GetById(domain.Id)!.Items);
}
// ── CompetencyTools: Bildungsplan-Versionen ─────────────────────────────────────────────
[Fact]
public void GetCompetencyPlanVersions_ListetVersionenDesFachs()
{
var subjectId = Guid.NewGuid();
var versions = new FakeCompetencyCatalogVersions();
versions.GetOrCreateDefault(subjectId);
var tool = new CompetencyTools(new FakeSubjects([]), new FakeCompetencyDomains(), versions, new FakeMcpConfirmation());
var result = Assert.Single(tool.GetCompetencyPlanVersions(subjectId));
Assert.True(result.IsDefault);
Assert.Equal("Bestehender Kompetenzkatalog", result.Name);
}
[Fact]
public async Task CreateCompetencyPlanVersion_NutzerBestaetigt_SpeichertVersion()
{
var subject = new Subject { Name = "Chemie" };
var versions = new FakeCompetencyCatalogVersions();
var tool = new CompetencyTools(new FakeSubjects([subject]), new FakeCompetencyDomains(), versions, new FakeMcpConfirmation());
var result = await tool.CreateCompetencyPlanVersion(subject.Id, "Bildungsplan 2026");
Assert.True(result.Applied);
Assert.Single(versions.GetBySubject(subject.Id), v => v.Name == "Bildungsplan 2026");
}
[Fact]
public async Task UpdateCompetencyPlanVersion_SetAsDefault_NimmtBisherigeStandardversionZurueck()
{
var subjectId = Guid.NewGuid();
var versions = new FakeCompetencyCatalogVersions();
var alt = versions.GetOrCreateDefault(subjectId);
var neu = new CompetencyCatalogVersion { SubjectId = subjectId, Name = "Bildungsplan 2026" };
versions.Add(neu);
var tool = new CompetencyTools(new FakeSubjects([]), new FakeCompetencyDomains(), versions, new FakeMcpConfirmation());
var result = await tool.UpdateCompetencyPlanVersion(neu.Id, setAsDefault: true);
Assert.True(result.Applied);
Assert.False(versions.GetById(alt.Id)!.IsDefault);
Assert.True(versions.GetById(neu.Id)!.IsDefault);
}
[Fact]
public async Task DeleteCompetencyPlanVersion_Standardversion_LiefertFehler()
{
var versions = new FakeCompetencyCatalogVersions();
var version = versions.GetOrCreateDefault(Guid.NewGuid());
var tool = new CompetencyTools(new FakeSubjects([]), new FakeCompetencyDomains(), versions, new FakeMcpConfirmation());
var result = await tool.DeleteCompetencyPlanVersion(version.Id);
Assert.False(result.Applied);
}
// ── GroupTools: Bildungsplan-Version eines Kurses ───────────────────────────────────────
[Fact]
public async Task UpdateGroupCompetencyVersion_NutzerBestaetigt_SpeichertVersion()
{
var subjectId = Guid.NewGuid();
var group = new LearningGroup { Name = "9a", SchoolYear = "2025/26", SubjectId = subjectId };
var groups = new FakeGroups([group]);
var versions = new FakeCompetencyCatalogVersions();
var version = new CompetencyCatalogVersion { SubjectId = subjectId, Name = "Bildungsplan 2026" };
versions.Add(version);
var tool = new GroupTools(groups, versions, new FakeMcpConfirmation());
var result = await tool.UpdateGroupCompetencyVersion(group.Id, version.Id);
Assert.True(result.Applied);
Assert.Equal(version.Id, groups.GetById(group.Id)!.CompetencyCatalogVersionId);
}
[Fact]
public async Task UpdateGroupCompetencyVersion_UnbekannteVersion_LiefertFehlerOhneNachfrage()
{
var group = new LearningGroup { Name = "9a", SchoolYear = "2025/26" };
var groups = new FakeGroups([group]);
var confirmation = new FakeMcpConfirmation();
var tool = new GroupTools(groups, new FakeCompetencyCatalogVersions(), confirmation);
var result = await tool.UpdateGroupCompetencyVersion(group.Id, Guid.NewGuid());
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
}
@@ -45,7 +45,7 @@ public sealed class ParticipationSessionEditingTests
var sessions = new FakeSessions([session]);
var vm = new ParticipationTabViewModel(
sessions, new FakeEntries(), new FakeAspects(), new FakeStudents([]),
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains());
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions());
vm.OnEditSession = existing =>
{
existing.Comment = "Nachbesprechung Elektrizität";
@@ -70,7 +70,7 @@ public sealed class ParticipationSessionEditingTests
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = Guid.NewGuid() });
var vm = new ParticipationTabViewModel(
sessions, entries, new FakeAspects(), new FakeStudents([]),
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains());
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions());
vm.Initialize(group.Id, group.SchoolYear);
vm.SelectedSession = vm.Sessions.Single(s => s.Id == session.Id);
@@ -95,7 +95,7 @@ public sealed class ParticipationSessionEditingTests
var sessions = new FakeSessions([session]);
var vm = new ParticipationTabViewModel(
sessions, new FakeEntries(), new FakeAspects(), new FakeStudents([]),
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains());
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions());
vm.Initialize(group.Id, group.SchoolYear);
vm.SelectedSession = vm.Sessions.Single(s => s.Id == session.Id);
vm.OnConfirmDeleteSession = (_, _) => Task.FromResult(false);
@@ -20,7 +20,7 @@ public class PlanningTabViewModelTests
var lessons = new FakeLessons();
var vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains,
TestSupport.BuildAiSettingsService(), new FakeSessions([]));
new FakeCompetencyCatalogVersions(), TestSupport.BuildAiSettingsService(), new FakeSessions([]));
vm.Initialize(groupId);
return (vm, units, lessons, groupId);
@@ -277,7 +277,7 @@ public class PlanningTabViewModelTests
var lessons = new FakeLessons();
var sessions = new FakeSessions([]);
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), TestSupport.BuildAiSettingsService(), sessions);
vm.Initialize(groupId);
var unit = new Unit { GroupId = groupId, Title = "Optik" };
@@ -312,7 +312,7 @@ public class PlanningTabViewModelTests
var lessons = new FakeLessons();
var sessions = new FakeSessions([]);
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), TestSupport.BuildAiSettingsService(), sessions);
vm.Initialize(groupId);
var unit = new Unit { GroupId = groupId, Title = "Optik" };
@@ -346,7 +346,7 @@ public class PlanningTabViewModelTests
var lessons = new FakeLessons();
var sessions = new FakeSessions([]);
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), TestSupport.BuildAiSettingsService(), sessions);
vm.Initialize(groupId);
var unit = new Unit { GroupId = groupId, Title = "Optik" };
@@ -16,6 +16,7 @@ public sealed class SettingsViewModelTests
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
FakeSupervisionDuties? supervisionDuties = null,
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null,
FakeCompetencyCatalogVersions? competencyVersions = null,
EventQueue? eventQueue = null, SyncKeyStatus? syncKeyStatus = null,
SyncKeyRecoveryService? syncKeyRecovery = null, AppearanceSettingsService? appearance = null,
TrashViewModel? trashTab = null)
@@ -27,7 +28,8 @@ public sealed class SettingsViewModelTests
Directory.CreateDirectory(tempPath);
return new SettingsViewModel(
subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(),
competencyVersions ?? new FakeCompetencyCatalogVersions(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new BackupSettingsService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath),
@@ -335,7 +337,7 @@ public sealed class SettingsViewModelTests
var calendarSettings = new SchoolCalendarSettingsService(tempPath);
var vm = new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new BackupSettingsService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath),
@@ -365,7 +367,7 @@ public sealed class SettingsViewModelTests
var periodSchedule = new PeriodScheduleService(tempPath);
var vm = new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new BackupSettingsService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath),
@@ -399,7 +401,7 @@ public sealed class SettingsViewModelTests
var periodSchedule = new PeriodScheduleService(tempPath);
var vm = new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new BackupSettingsService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath),
@@ -489,21 +491,24 @@ public sealed class SettingsViewModelTests
{
var subject = new Subject { Name = "Biologie", ShortName = "Bio" };
var repository = new FakeCompetencyDomains();
var versions = new FakeCompetencyCatalogVersions();
var version = versions.GetOrCreateDefault(subject.Id);
repository.Add(new CompetencyDomain
{
SubjectId = subject.Id, GradeLevel = 10, Name = "Erster Bereich", SortOrder = 0,
SubjectId = subject.Id, GradeLevel = 10, VersionId = version.Id, Name = "Erster Bereich", SortOrder = 0,
});
repository.Add(new CompetencyDomain
{
SubjectId = subject.Id, GradeLevel = 10, Name = "Zweiter Bereich", SortOrder = 1,
SubjectId = subject.Id, GradeLevel = 10, VersionId = version.Id, Name = "Zweiter Bereich", SortOrder = 1,
});
var vm = BuildViewModel(subjects: new FakeSubjects([subject]), competencyDomains: repository);
var vm = BuildViewModel(subjects: new FakeSubjects([subject]), competencyDomains: repository,
competencyVersions: versions);
vm.CatalogSubject = vm.Subjects.Single();
vm.Domains[1].MoveUpCommand.Execute(null);
Assert.Equal(["Zweiter Bereich", "Erster Bereich"], vm.Domains.Select(x => x.Name));
var stored = repository.GetBySubjectAndGrade(subject.Id, 10);
var stored = repository.GetBySubjectAndGrade(subject.Id, 10, version.Id);
Assert.Equal(["Zweiter Bereich", "Erster Bereich"], stored.Select(x => x.Name));
Assert.Equal([0, 1], stored.Select(x => x.SortOrder));
}
@@ -537,15 +542,19 @@ public sealed class SettingsViewModelTests
{
var subject = new Subject { Name = "Biologie", ShortName = "Bio" };
var repository = new FakeCompetencyDomains();
var versions = new FakeCompetencyCatalogVersions();
var version = versions.GetOrCreateDefault(subject.Id);
repository.Add(new CompetencyDomain
{
SubjectId = subject.Id,
GradeLevel = 10,
VersionId = version.Id,
Name = "Erkenntnisgewinnung",
Code = "EG",
Items = [new CompetencyItem { Code = "EG1", Description = "Modelle verwenden" }],
});
var vm = BuildViewModel(subjects: new FakeSubjects([subject]), competencyDomains: repository);
var vm = BuildViewModel(subjects: new FakeSubjects([subject]), competencyDomains: repository,
competencyVersions: versions);
vm.CatalogSubject = vm.Subjects.Single();
var preview = vm.PrepareCatalogCopy(11);
@@ -556,9 +565,9 @@ public sealed class SettingsViewModelTests
Assert.Equal(1, preview.NewCompetencies);
vm.ApplyCatalogImport(preview, CompetencyCatalogImportMode.Merge, new HashSet<string>());
var copied = repository.GetBySubjectAndGrade(subject.Id, 11);
var copied = repository.GetBySubjectAndGrade(subject.Id, 11, version.Id);
Assert.Single(copied);
Assert.NotEqual(repository.GetBySubjectAndGrade(subject.Id, 10)[0].Id, copied[0].Id);
Assert.NotEqual(repository.GetBySubjectAndGrade(subject.Id, 10, version.Id)[0].Id, copied[0].Id);
Assert.Equal("EG1", copied[0].Items.Single().Code);
}
}
@@ -22,7 +22,7 @@ public sealed class TeachingModeViewModelTests
FakeSessions? sessions = null) =>
new(sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects(),
new FakeStudents([]), new FakeMemberships([]), new FakeGroups([group]),
new FakeCompetencyDomains());
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions());
[Fact]
public void Konstruktor_LaedtVerlaufsplanUndInitialisiertSitzplanFuerDieGruppe()
+1
View File
@@ -176,6 +176,7 @@ public static class AppBootstrapper
services.AddSingleton<IParticipationSectionRepository, ParticipationSectionRepository>();
services.AddSingleton<ISubjectRepository, SubjectRepository>();
services.AddSingleton<ICompetencyDomainRepository, CompetencyDomainRepository>();
services.AddSingleton<ICompetencyCatalogVersionRepository, CompetencyCatalogVersionRepository>();
services.AddSingleton<IShorthandCodeRepository, ShorthandCodeRepository>();
services.AddSingleton<IAlternativeLessonPathRepository, AlternativeLessonPathRepository>();
services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>();
@@ -1,6 +1,7 @@
using LehrerApp.Core.AiPlanning;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
@@ -72,7 +73,8 @@ public class GermanTimeOnlyJsonConverter : JsonConverter<TimeOnly>
/// </summary>
public class AiPlanningService(HttpClient http, ILessonRepository lessons,
IGroupRepository groups, ISubjectRepository subjects,
ICompetencyDomainRepository competencyDomains, IAlternativeLessonPathRepository altPaths)
ICompetencyDomainRepository competencyDomains, ICompetencyCatalogVersionRepository competencyVersions,
IAlternativeLessonPathRepository altPaths)
{
// Wire-Format zum PHP-Backend ist camelCase (siehe ai-backend/) — beide Seiten sind hier
// im eigenen Zugriff, daher bewusst konsistent camelCase statt der C#-üblichen PascalCase-Defaults.
@@ -165,7 +167,8 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
var competencyCatalog = subject is null
? []
: competencyDomains.GetBySubjectAndGrade(subject.Id, gradeLevel)
: competencyDomains.GetBySubjectAndGrade(subject.Id, gradeLevel,
CompetencyCatalogVersionResolver.Resolve(competencyVersions, subject.Id, group?.CompetencyCatalogVersionId))
.Select(d => new AiCompetencyDomain
{
Name = d.Name,
@@ -177,8 +177,10 @@ public sealed class McpServerHostedService : IAsyncDisposable
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
AddReadTool(competencyTools.GetSubjects, "get_subjects",
"Listet alle Fächer.");
AddReadTool(competencyTools.GetCompetencyPlanVersions, "get_competency_plan_versions",
"Listet die Bildungsplan-Versionen eines Fachs.");
AddReadTool(competencyTools.GetCompetencyCatalog, "get_competency_catalog",
"Listet den Kompetenzkatalog eines Fachs, optional gefiltert auf eine Klassenstufe.");
"Listet den Kompetenzkatalog eines Fachs für eine Bildungsplan-Version, optional gefiltert auf eine Klassenstufe.");
AddReadTool(untisComparisonTools.GetUntisHubStatus, "get_untis_hub_status",
"Listet die Fälligkeit der Untis-Hub-Abgleiche, ohne selbst WebUntis anzufragen.");
AddReadTool(untisComparisonTools.GetUntisAbsenceRows, "get_untis_absence_rows",
@@ -228,6 +230,12 @@ public sealed class McpServerHostedService : IAsyncDisposable
"Ändert Code/Beschreibung einer Einzelkompetenz (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.RemoveCompetencyItem, "remove_competency_item",
"Entfernt eine Einzelkompetenz aus einem Kompetenzbereich (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.CreateCompetencyPlanVersion, "create_competency_plan_version",
"Legt eine neue Bildungsplan-Version für ein Fach an (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.UpdateCompetencyPlanVersion, "update_competency_plan_version",
"Ändert Name/Standardstatus einer Bildungsplan-Version (Bestätigung durch den Nutzer nötig).");
AddWriteTool(groupTools.UpdateGroupCompetencyVersion, "update_group_competency_version",
"Setzt oder löscht die Bildungsplan-Version eines Kurses/einer Klasse (Bestätigung durch den Nutzer nötig).");
AddWriteTool(untisComparisonTools.ApplyUntisAbsenceStatus, "apply_untis_absence_status",
"Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id (Bestätigung durch den Nutzer nötig, nennt keinen Schülernamen).");
@@ -237,6 +245,8 @@ public sealed class McpServerHostedService : IAsyncDisposable
"Löscht ein Fach endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
AddDestructiveWriteTool(competencyTools.DeleteCompetencyDomain, "delete_competency_domain",
"Löscht einen Kompetenzbereich endgültig samt Einzelkompetenzen, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
AddDestructiveWriteTool(competencyTools.DeleteCompetencyPlanVersion, "delete_competency_plan_version",
"Löscht eine Bildungsplan-Version endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
System.Diagnostics.Debug.Assert(
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
@@ -29,6 +29,7 @@ public static class McpToolScope
"list_letter_templates",
"render_letter",
"get_subjects",
"get_competency_plan_versions",
"get_competency_catalog",
"get_untis_hub_status",
"get_untis_absence_rows",
@@ -63,6 +64,9 @@ public static class McpToolScope
"add_competency_item",
"update_competency_item",
"remove_competency_item",
"create_competency_plan_version",
"update_competency_plan_version",
"update_group_competency_version",
"apply_untis_absence_status",
];
@@ -77,5 +81,6 @@ public static class McpToolScope
"delete_lesson",
"delete_subject",
"delete_competency_domain",
"delete_competency_plan_version",
];
}
@@ -5,18 +5,21 @@ using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Tools für die Fach- und Kompetenzkatalog-Verwaltung (Nutzer-Nachtrag zu 4.5.28ff.):
/// <see cref="Subject"/> und <see cref="CompetencyDomain"/>/<see cref="CompetencyItem"/> vollständig
/// anlegen/ändern/löschen. Die Zuordnung von Kompetenzcodes zu einzelnen Stunden lebt bewusst in
/// <see cref="LessonPlanTools"/> (AddLessonCompetency/RemoveLessonCompetency), nicht hier - sie
/// gehört fachlich zur Stundenverwaltung, nicht zum Katalog selbst.
/// <summary>MCP-Tools für die Fach- und Kompetenzkatalog-Verwaltung (Nutzer-Nachtrag zu 4.5.28ff.,
/// später um Bildungsplan-Versionierung erweitert): <see cref="Subject"/>,
/// <see cref="CompetencyCatalogVersion"/> und <see cref="CompetencyDomain"/>/<see cref="CompetencyItem"/>
/// vollständig anlegen/ändern/löschen. Die Zuordnung von Kompetenzcodes zu einzelnen Stunden lebt
/// bewusst in <see cref="LessonPlanTools"/> (AddLessonCompetency/RemoveLessonCompetency), nicht hier -
/// sie gehört fachlich zur Stundenverwaltung, nicht zum Katalog selbst.
///
/// "delete_subject" und "delete_competency_domain" sind wie "delete_lesson" (LessonPlanTools)
/// endgültige Löschungen ohne Papierkorb (die Repository-Implementierungen löschen direkt in der
/// LiteDB-Collection statt über MoveToTrash) - deshalb als Destructive registriert (siehe
/// McpServerHostedService) und mit entsprechend deutlicher Bestätigungsnachricht.</summary>
/// "delete_subject", "delete_competency_domain" und "delete_competency_plan_version" sind wie
/// "delete_lesson" (LessonPlanTools) endgültige Löschungen ohne Papierkorb (die
/// Repository-Implementierungen löschen direkt in der LiteDB-Collection statt über MoveToTrash) -
/// deshalb als Destructive registriert (siehe McpServerHostedService) und mit entsprechend
/// deutlicher Bestätigungsnachricht.</summary>
public class CompetencyTools(
ISubjectRepository subjects, ICompetencyDomainRepository domains, IMcpConfirmationService confirmation)
ISubjectRepository subjects, ICompetencyDomainRepository domains,
ICompetencyCatalogVersionRepository versions, IMcpConfirmationService confirmation)
{
// ── Lesen ────────────────────────────────────────────────────────────────────────────────
@@ -24,17 +27,93 @@ public class CompetencyTools(
public List<SubjectDto> GetSubjects() =>
subjects.GetAll().Select(s => new SubjectDto(s.Id, s.Name, s.ShortName)).ToList();
[Description("Listet den Kompetenzkatalog (Bereiche mit Einzelkompetenzen) eines Fachs, optional gefiltert auf eine Klassenstufe.")]
[Description("Listet die Bildungsplan-Versionen eines Fachs (z.B. \"Bildungsplan 2016\" vs. \"Bildungsplan 2026\", während ein neuer Plan klassenstufenweise hochwächst). Genau eine Version je Fach ist die Standardversion.")]
public List<CompetencyPlanVersionDto> GetCompetencyPlanVersions(
[Description("Fach-ID, aus get_subjects.")] Guid subjectId) =>
versions.GetBySubject(subjectId)
.Select(v => new CompetencyPlanVersionDto(v.Id, v.SubjectId, v.Name, v.IsDefault, v.SortOrder))
.ToList();
[Description("Listet den Kompetenzkatalog (Bereiche mit Einzelkompetenzen) eines Fachs für eine Bildungsplan-Version, optional gefiltert auf eine Klassenstufe.")]
public List<CompetencyDomainDto> GetCompetencyCatalog(
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
[Description("Optionale Klassenstufe. Weglassen: alle Klassenstufen dieses Fachs.")] int? gradeLevel = null)
[Description("Optionale Klassenstufe. Weglassen: alle Klassenstufen dieser Version.")] int? gradeLevel = null,
[Description("Bildungsplan-Version, aus get_competency_plan_versions. Weglassen: Standardversion des Fachs.")] Guid? versionId = null)
{
var effectiveVersionId = versionId ?? versions.GetOrCreateDefault(subjectId).Id;
var result = gradeLevel is not null
? domains.GetBySubjectAndGrade(subjectId, gradeLevel.Value)
: domains.GetBySubject(subjectId);
? domains.GetBySubjectAndGrade(subjectId, gradeLevel.Value, effectiveVersionId)
: domains.GetBySubjectAndVersion(subjectId, effectiveVersionId);
return result.Select(ToDto).ToList();
}
// ── Bildungsplan-Versionen (CompetencyCatalogVersion) ────────────────────────────────────
[Description("Legt eine neue Bildungsplan-Version für ein Fach an (z.B. \"Bildungsplan 2026\"), um einen neuen, parallel gültigen Kompetenzkatalog zu pflegen. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> CreateCompetencyPlanVersion(
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
[Description("Name der Version, z.B. \"Bildungsplan 2026\".")] string name,
CancellationToken ct = default)
{
var subject = subjects.GetById(subjectId);
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Name der Version darf nicht leer sein.");
var message = $"Neue Bildungsplan-Version „{name.Trim()}“ für {subject.Name} anlegen?";
if (!await confirmation.ConfirmAsync("Version anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var version = new CompetencyCatalogVersion
{
SubjectId = subjectId, Name = name, SortOrder = versions.GetBySubject(subjectId).Count,
};
try { versions.Save(version); }
catch (ArgumentException ex) { return new WriteResultDto(false, null, ex.Message); }
return new WriteResultDto(true, version.Id, "Version gespeichert.");
}
[Description("Ändert Name/Standardstatus einer bestehenden Bildungsplan-Version. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateCompetencyPlanVersion(
[Description("ID der Version, aus get_competency_plan_versions.")] Guid versionId,
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
[Description("Auf true setzen, um diese Version zur Standardversion ihres Fachs zu machen (bisherige Standardversion wird automatisch zurückgenommen).")] bool? setAsDefault = null,
CancellationToken ct = default)
{
var version = versions.GetById(versionId);
if (version is null) return new WriteResultDto(false, null, "Unbekannte Versions-ID.");
var changes = new StringBuilder();
if (name is not null && name != version.Name) { changes.AppendLine($"Name: „{version.Name}“ → „{name}“"); version.Name = name; }
if (setAsDefault is true && !version.IsDefault) { changes.AppendLine("wird Standardversion des Fachs"); version.IsDefault = true; }
if (changes.Length == 0)
return new WriteResultDto(true, version.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Version ändern?", $"„{version.Name}“\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
try { versions.Save(version); }
catch (ArgumentException ex) { return new WriteResultDto(false, null, ex.Message); }
return new WriteResultDto(true, version.Id, "Version gespeichert.");
}
[Description("Löscht eine Bildungsplan-Version endgültig. Schlägt fehl, wenn sie noch Kompetenzbereiche oder Kurse referenziert, oder wenn sie die Standardversion ihres Fachs ist. Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> DeleteCompetencyPlanVersion(
[Description("ID der Version.")] Guid versionId,
CancellationToken ct = default)
{
var version = versions.GetById(versionId);
if (version is null) return new WriteResultDto(false, null, "Unbekannte Versions-ID.");
var message = $"Die Bildungsplan-Version „{version.Name}“ wird endgültig gelöscht. Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Versionen). Schlägt fehl, falls noch Kompetenzbereiche oder Kurse diese Version nutzen.";
if (!await confirmation.ConfirmAsync("Version endgültig löschen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
try { versions.Delete(versionId); }
catch (InvalidOperationException ex) { return new WriteResultDto(false, null, ex.Message); }
return new WriteResultDto(true, versionId, "Version gelöscht.");
}
// ── Fächer (Subject) ─────────────────────────────────────────────────────────────────────
[Description("Legt ein neues Fach an. Muss der Nutzer erst bestätigen.")]
@@ -109,26 +188,30 @@ public class CompetencyTools(
// ── Kompetenzbereiche (CompetencyDomain) ────────────────────────────────────────────────
[Description("Legt einen neuen Kompetenzbereich (z.B. \"Zahlen und Operationen\") für ein Fach und eine Klassenstufe an, zunächst ohne Einzelkompetenzen. Muss der Nutzer erst bestätigen.")]
[Description("Legt einen neuen Kompetenzbereich (z.B. \"Zahlen und Operationen\") für ein Fach, eine Klassenstufe und eine Bildungsplan-Version an, zunächst ohne Einzelkompetenzen. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> CreateCompetencyDomain(
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
[Description("Klassenstufe.")] int gradeLevel,
[Description("Name des Bereichs.")] string name,
[Description("Optionales Kürzel, z.B. \"ZO\".")] string code = "",
[Description("Bildungsplan-Version, aus get_competency_plan_versions. Weglassen: Standardversion des Fachs.")] Guid? versionId = null,
CancellationToken ct = default)
{
var subject = subjects.GetById(subjectId);
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Bereichsname darf nicht leer sein.");
var sortOrder = domains.GetBySubjectAndGrade(subjectId, gradeLevel).Count;
var message = $"Neuen Kompetenzbereich „{name.Trim()}“ für {subject.Name}, Klasse {gradeLevel}, anlegen?";
var effectiveVersionId = versionId ?? versions.GetOrCreateDefault(subjectId).Id;
var versionName = versions.GetById(effectiveVersionId)?.Name ?? "";
var sortOrder = domains.GetBySubjectAndGrade(subjectId, gradeLevel, effectiveVersionId).Count;
var message = $"Neuen Kompetenzbereich „{name.Trim()}“ für {subject.Name}, Klasse {gradeLevel} ({versionName}), anlegen?";
if (!await confirmation.ConfirmAsync("Kompetenzbereich anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var domain = new CompetencyDomain
{
SubjectId = subjectId, GradeLevel = gradeLevel, Name = name, Code = code, SortOrder = sortOrder,
SubjectId = subjectId, GradeLevel = gradeLevel, VersionId = effectiveVersionId,
Name = name, Code = code, SortOrder = sortOrder,
};
domains.Save(domain);
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
@@ -246,7 +329,7 @@ public class CompetencyTools(
}
private static CompetencyDomainDto ToDto(CompetencyDomain d) => new(
d.Id, d.SubjectId, d.GradeLevel, d.Name, d.Code, d.SortOrder,
d.Id, d.SubjectId, d.GradeLevel, d.VersionId, d.Name, d.Code, d.SortOrder,
d.Items.OrderBy(i => i.SortOrder)
.Select(i => new CompetencyItemDto(i.Id, i.Code, i.Description, i.SortOrder)).ToList());
}
+5 -2
View File
@@ -56,9 +56,12 @@ public record SubjectDto(Guid Id, string Name, string ShortName);
public record CompetencyItemDto(Guid Id, string Code, string Description, int SortOrder);
public record CompetencyDomainDto(
Guid Id, Guid SubjectId, int GradeLevel, string Name, string Code, int SortOrder,
Guid Id, Guid SubjectId, int GradeLevel, Guid VersionId, string Name, string Code, int SortOrder,
List<CompetencyItemDto> Items);
/// <summary>Eine Bildungsplan-Version eines Fachs, siehe CompetencyTools.GetCompetencyPlanVersions.</summary>
public record CompetencyPlanVersionDto(Guid Id, Guid SubjectId, string Name, bool IsDefault, int SortOrder);
public record GroupMembershipDto(
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
@@ -91,7 +94,7 @@ public record UntisHubStatusRowDto(
/// Fach-Datensatz mitzuschleppen.</summary>
public record GroupDto(
Guid Id, string Name, GroupType Type, string SchoolYear, int GradeLevel,
Guid? SubjectId, bool IsActive);
Guid? SubjectId, bool IsActive, Guid? CompetencyPlanVersionId);
/// <summary>Anonymisierte Fehlzeiten-Diskrepanz (siehe UntisComparisonTools.GetUntisAbsenceRows):
/// bewusst KEIN Schülername/keine Klasse - <see cref="RowId"/> ist die einzige Kennung, über die
@@ -1,14 +1,18 @@
using System.ComponentModel;
using System.Text;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_groups" (Nutzer-Nachtrag: die GroupId, die praktisch jedes andere
/// Tool als Pflichtparameter verlangt - get_grades, get_schedule, get_lesson_plans,
/// get_untis_absence_rows usw. - war nirgends über MCP auflösbar; ein KI-Client kannte bestenfalls
/// den Klarnamen einer Lerngruppe aus dem Gespräch, nie ihre Id). Reiner Lesezugriff auf das
/// bestehende Repository, keine eigene Datenzugriffslogik.</summary>
public class GroupTools(IGroupRepository groups)
/// <summary>MCP-Tools für Lerngruppen (Klassen/Kurse). "get_groups" (Nutzer-Nachtrag: die GroupId,
/// die praktisch jedes andere Tool als Pflichtparameter verlangt - get_grades, get_schedule,
/// get_lesson_plans, get_untis_absence_rows usw. - war nirgends über MCP auflösbar; ein KI-Client
/// kannte bestenfalls den Klarnamen einer Lerngruppe aus dem Gespräch, nie ihre Id) ist reiner
/// Lesezugriff. "update_group_competency_version" ist das erste Schreib-Tool auf einem
/// LearningGroup-Feld selbst (Nutzer-Nachtrag zur Bildungsplan-Versionierung) - deshalb bewusst eng
/// auf genau dieses eine Feld geschnitten statt eines allgemeinen "update_group".</summary>
public class GroupTools(IGroupRepository groups, ICompetencyCatalogVersionRepository versions,
IMcpConfirmationService confirmation)
{
[Description("Listet Lerngruppen (Klassen/Kurse) mit ihrer Id - Voraussetzung, um andere Tools (z.B. get_grades, get_schedule, get_untis_absence_rows) für eine bestimmte Gruppe aufzurufen, wenn nur ihr Name bekannt ist. Ohne schoolYear werden alle Schuljahre zurückgegeben.")]
public List<GroupDto> GetGroups(
@@ -18,6 +22,32 @@ public class GroupTools(IGroupRepository groups)
var list = schoolYear is null
? groups.GetAll(includeInactive)
: groups.GetBySchoolYear(schoolYear, includeInactive);
return list.Select(g => new GroupDto(g.Id, g.Name, g.Type, g.SchoolYear, g.GradeLevel, g.SubjectId, g.IsActive)).ToList();
return list.Select(g => new GroupDto(g.Id, g.Name, g.Type, g.SchoolYear, g.GradeLevel,
g.SubjectId, g.IsActive, g.CompetencyCatalogVersionId)).ToList();
}
[Description("Setzt oder löscht die Bildungsplan-Version eines Kurses/einer Klasse (welcher Kompetenzkatalog für diese Gruppe gilt). Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> UpdateGroupCompetencyVersion(
[Description("Gruppen-ID, aus get_groups.")] Guid groupId,
[Description("Bildungsplan-Version, aus get_competency_plan_versions. Weglassen: zurück auf die Standardversion des Fachs.")] Guid? versionId = null,
CancellationToken ct = default)
{
var group = groups.GetById(groupId);
if (group is null) return new WriteResultDto(false, null, "Unbekannte Gruppen-ID.");
if (versionId is Guid vid && versions.GetById(vid) is null)
return new WriteResultDto(false, null, "Unbekannte Versions-ID.");
var oldName = group.CompetencyCatalogVersionId is Guid oldId ? versions.GetById(oldId)?.Name ?? "unbekannt" : "Standardversion";
var newName = versionId is Guid newId ? versions.GetById(newId)?.Name ?? "unbekannt" : "Standardversion";
if (oldName == newName && group.CompetencyCatalogVersionId == versionId)
return new WriteResultDto(true, group.Id, "Keine Änderung nötig.");
var message = new StringBuilder($"Bildungsplan-Version von „{group.Name}“ ändern:\n{oldName} → {newName}");
if (!await confirmation.ConfirmAsync("Bildungsplan-Version ändern?", message.ToString(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
group.CompetencyCatalogVersionId = versionId;
groups.Save(group);
return new WriteResultDto(true, group.Id, "Bildungsplan-Version gespeichert.");
}
}
@@ -13,6 +13,7 @@ public partial class CompetencyOverviewTabViewModel : ObservableObject
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly ICompetencyDomainRepository _catalog;
private readonly ICompetencyCatalogVersionRepository _versions;
private readonly IStudentRepository _students;
private readonly CompetencyAnalysisService _analysis;
private LearningGroup? _group;
@@ -23,6 +24,7 @@ public partial class CompetencyOverviewTabViewModel : ObservableObject
[ObservableProperty] private string _emptyMessage = "";
public string GroupLabel => _group?.Name ?? "";
public string VersionLabel { get; private set; } = "";
public ObservableCollection<CompetencyStudentOption> StudentOptions { get; } = [];
public ObservableCollection<CompetencyOverviewRow> Rows { get; } = [];
@@ -32,12 +34,14 @@ public partial class CompetencyOverviewTabViewModel : ObservableObject
public CompetencyOverviewTabViewModel(IUnitRepository units, IExamRepository exams,
IExamResultRepository results, ICompetencyDomainRepository catalog,
ICompetencyCatalogVersionRepository versions,
IStudentRepository students, CompetencyAnalysisService analysis)
{
_units = units;
_exams = exams;
_results = results;
_catalog = catalog;
_versions = versions;
_students = students;
_analysis = analysis;
}
@@ -53,9 +57,18 @@ public partial class CompetencyOverviewTabViewModel : ObservableObject
{
if (_group is null) return;
var selectedStudentId = SelectedStudent?.Id;
var domains = _group.SubjectId is Guid subjectId
? _catalog.GetBySubjectAndGrade(subjectId, _group.GradeLevel)
: [];
List<CompetencyDomain> domains;
if (_group.SubjectId is Guid subjectId)
{
var versionId = CompetencyCatalogVersionResolver.Resolve(_versions, subjectId, _group.CompetencyCatalogVersionId);
VersionLabel = _versions.GetById(versionId)?.Name ?? "";
domains = _catalog.GetBySubjectAndGrade(subjectId, _group.GradeLevel, versionId);
}
else
{
VersionLabel = "";
domains = [];
}
var units = _units.GetByGroup(_group.Id);
var exams = _exams.GetByGroup(_group.Id);
var results = exams.SelectMany(x => _results.GetByExam(x.Id)).ToList();
@@ -81,6 +94,7 @@ public partial class CompetencyOverviewTabViewModel : ObservableObject
: "";
OnPropertyChanged(nameof(HasRows));
OnPropertyChanged(nameof(HasStudents));
OnPropertyChanged(nameof(VersionLabel));
}
partial void OnSelectedStudentChanged(CompetencyStudentOption? value)
@@ -19,6 +19,7 @@ public partial class ExamDialogViewModel : ObservableObject
private readonly Guid _groupId;
private readonly Guid? _subjectId;
private readonly int _gradeLevel;
private readonly Guid _competencyCatalogVersionId;
private readonly GradingSystem _gradingSystem;
private readonly Exam? _editingExam;
private readonly Exam? _duplicateSource;
@@ -63,11 +64,13 @@ public partial class ExamDialogViewModel : ObservableObject
public ExamDialogViewModel(IExamRepository exams, ICompetencyDomainRepository competencyDomains,
IGradingKeyTemplateRepository gradingKeyTemplates, GradingService grading,
Guid groupId, Guid? subjectId, int gradeLevel, GradingSystem gradingSystem,
string defaultSubjectName, bool isDifferentiated, Exam? editingExam, Exam? duplicateSource)
string defaultSubjectName, bool isDifferentiated, Exam? editingExam, Exam? duplicateSource,
Guid competencyCatalogVersionId = default)
{
_exams = exams; _competencyDomains = competencyDomains;
_gradingKeyTemplates = gradingKeyTemplates; _grading = grading;
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
_competencyCatalogVersionId = competencyCatalogVersionId;
_gradingSystem = gradingSystem;
_editingExam = editingExam;
_duplicateSource = duplicateSource;
@@ -75,7 +78,7 @@ public partial class ExamDialogViewModel : ObservableObject
IsDifferentiated = isDifferentiated;
HasCompetencyCatalog = subjectId.HasValue
&& _competencyDomains.GetBySubjectAndGrade(subjectId.Value, gradeLevel).Count > 0;
&& _competencyDomains.GetBySubjectAndGrade(subjectId.Value, gradeLevel, competencyCatalogVersionId).Count > 0;
foreach (var t in _gradingKeyTemplates.GetByGradingSystem(gradingSystem))
AvailableTemplates.Add(t);
@@ -164,7 +167,7 @@ public partial class ExamDialogViewModel : ObservableObject
var groups = new List<CompetencyTagGroup>();
if (!_subjectId.HasValue) return groups;
var selected = selectedCodes.ToHashSet();
foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel))
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))
@@ -799,8 +799,10 @@ public partial class AddGroupDialogViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly ICompetencyCatalogVersionRepository _competencyVersions;
private List<Subject> _allSubjects = [];
private LearningGroup? _editingGroup;
private Guid? _pendingCompetencyVersionId;
public List<string> TypeOptions { get; } = ["Klasse", "Kurs"];
[ObservableProperty] private string _selectedTypeName = "Kurs";
@@ -829,18 +831,21 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private bool _excludedFromUntisHub;
[ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _gradeLevelError = "";
[ObservableProperty] private CompetencyVersionListItem? _selectedCompetencyVersion;
public List<string> SchoolYears { get; }
public List<string> GradingOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
public List<string> KnownSubjectNames { get; private set; } = [];
public ObservableCollection<CompetencyVersionListItem> AvailableCompetencyVersions { get; } = [];
public bool HasCompetencyVersionChoice => AvailableCompetencyVersions.Count > 0;
public LearningGroup? Result { get; private set; }
public string DialogTitle => _editingGroup is null ? "Neue Lerngruppe anlegen" : "Lerngruppe bearbeiten";
public string SaveButtonText => _editingGroup is null ? "Anlegen" : "Speichern";
public AddGroupDialogViewModel(IGroupRepository groups, ISubjectRepository subjects,
SchoolYearService sy)
ICompetencyCatalogVersionRepository competencyVersions, SchoolYearService sy)
{
_groups = groups; _subjects = subjects;
_groups = groups; _subjects = subjects; _competencyVersions = competencyVersions;
_allSubjects = subjects.GetAll();
KnownSubjectNames = _allSubjects.Select(s => s.Name).ToList();
SchoolYears = sy.RecentSchoolYears(3);
@@ -852,11 +857,40 @@ public partial class AddGroupDialogViewModel : ObservableObject
};
}
partial void OnSubjectChanged(string value) => LoadCompetencyVersions();
/// <summary>Baut die Versionsliste für das aktuell im Freitextfeld <see cref="Subject"/> erkannte
/// Fach neu auf. Ein neues, noch nicht angelegtes Fach hat noch keine Versionen - die Auswahl
/// bleibt dann leer (<see cref="HasCompetencyVersionChoice"/> blendet den Picker aus), der Kurs
/// bekommt beim Speichern automatisch die Standardversion, sobald das Fach existiert.</summary>
private void LoadCompetencyVersions()
{
var targetId = _pendingCompetencyVersionId ?? SelectedCompetencyVersion?.Id;
_pendingCompetencyVersionId = null;
AvailableCompetencyVersions.Clear();
var subjectName = Subject.Trim();
var subject = subjectName.Length == 0 ? null : _allSubjects.FirstOrDefault(
s => s.Name.Equals(subjectName, StringComparison.OrdinalIgnoreCase));
if (subject is not null)
{
var versions = _competencyVersions.GetBySubject(subject.Id);
if (versions.Count == 0) versions = [_competencyVersions.GetOrCreateDefault(subject.Id)];
foreach (var v in versions) AvailableCompetencyVersions.Add(new CompetencyVersionListItem(v));
}
SelectedCompetencyVersion = AvailableCompetencyVersions.FirstOrDefault(v => v.Id == targetId)
?? AvailableCompetencyVersions.FirstOrDefault(v => v.IsDefault)
?? AvailableCompetencyVersions.FirstOrDefault();
OnPropertyChanged(nameof(HasCompetencyVersionChoice));
}
public void LoadForEdit(LearningGroup group)
{
_editingGroup = group;
SelectedTypeName = group.Type == GroupType.Class ? "Klasse" : "Kurs";
Name = group.Name;
_pendingCompetencyVersionId = group.CompetencyCatalogVersionId;
Subject = group.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : "";
GradeLevel = group.GradeLevel;
SelectedGradingName = group.GradingSystem == GradingSystem.Grades1To6
@@ -905,6 +939,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result = _editingGroup ?? new LearningGroup();
Result.Name = Name.Trim();
Result.SubjectId = subjectId;
Result.CompetencyCatalogVersionId = subjectId is null ? null : SelectedCompetencyVersion?.Id;
Result.Type = IsKurs ? GroupType.Course : GroupType.Class;
Result.GradeLevel = GradeLevel;
Result.GradingSystem = SelectedGradingName == "Punkte 0–15"
@@ -919,3 +954,13 @@ public partial class AddGroupDialogViewModel : ObservableObject
_groups.Save(Result);
}
}
/// <summary>Anzeige-Wrapper für den Bildungsplan-Versions-Picker im Kursdialog, analog zu
/// <see cref="SubjectListItem"/>.</summary>
public sealed class CompetencyVersionListItem(CompetencyCatalogVersion v)
{
public Guid Id { get; } = v.Id;
public string Name { get; } = v.Name;
public bool IsDefault { get; } = v.IsDefault;
public string DisplayName => IsDefault ? $"{Name} (Standard)" : Name;
}
@@ -19,11 +19,13 @@ public partial class ParticipationTabViewModel : ObservableObject
private readonly IGroupMembershipRepository _memberships;
private readonly IGroupRepository _groups;
private readonly ICompetencyDomainRepository _competencyDomains;
private readonly ICompetencyCatalogVersionRepository _competencyVersions;
private Guid _groupId;
private string _schoolYear = "";
private Guid? _subjectId;
private int _gradeLevel;
private Guid _competencyCatalogVersionId;
private GradingSystem _gradingSystem;
public Guid GroupId => _groupId;
@@ -63,12 +65,14 @@ public partial class ParticipationTabViewModel : ObservableObject
IStudentRepository students,
IGroupMembershipRepository memberships,
IGroupRepository groups,
ICompetencyDomainRepository competencyDomains)
ICompetencyDomainRepository competencyDomains,
ICompetencyCatalogVersionRepository competencyVersions)
{
_sessions = sessions; _entries = entries;
_aspects = aspects; _students = students;
_memberships = memberships; _groups = groups;
_competencyDomains = competencyDomains;
_competencyVersions = competencyVersions;
}
public void Initialize(Guid groupId, string schoolYear, bool isReadOnly = false)
@@ -80,11 +84,13 @@ public partial class ParticipationTabViewModel : ObservableObject
var group = _groups.GetById(groupId);
_subjectId = group?.SubjectId;
_gradeLevel = group?.GradeLevel ?? 0;
_competencyCatalogVersionId = CompetencyCatalogVersionResolver.Resolve(
_competencyVersions, _subjectId, group?.CompetencyCatalogVersionId);
_gradingSystem = group?.GradingSystem ?? GradingSystem.Grades1To6;
GroupLabel = group?.Name ?? "";
HasCompetencyCatalog = _subjectId.HasValue
&& _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel).Count > 0;
&& _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId).Count > 0;
LoadAspects();
LoadSessions();
@@ -236,7 +242,7 @@ public partial class ParticipationTabViewModel : ObservableObject
if (!_subjectId.HasValue) return;
var active = ActiveCompetencyCodes.ToHashSet();
foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel))
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))
@@ -34,6 +34,7 @@ public partial class PlanningTabViewModel : ObservableObject
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly ICompetencyDomainRepository _competencyDomains;
private readonly ICompetencyCatalogVersionRepository _competencyVersions;
private readonly AiSettingsService _aiSettings;
private readonly IParticipationSessionRepository _participationSessions;
@@ -44,6 +45,9 @@ public partial class PlanningTabViewModel : ObservableObject
public int GradeLevel { get; private set; }
public string SubjectName { get; private set; } = "";
public string GroupLabel { get; private set; } = "";
/// <summary>Aufgelöste Bildungsplan-Version dieser Gruppe (Gruppenwahl, sonst Standardversion des
/// Fachs) - <see cref="Guid.Empty"/> ohne Fach.</summary>
public Guid CompetencyCatalogVersionId { get; private set; }
[ObservableProperty] private UnitSummary? _selectedUnit;
[ObservableProperty] private LessonSummary? _selectedLesson;
@@ -85,11 +89,12 @@ public partial class PlanningTabViewModel : ObservableObject
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
IGroupRepository groups, ISubjectRepository subjects,
ICompetencyDomainRepository competencyDomains, AiSettingsService aiSettings,
IParticipationSessionRepository participationSessions)
ICompetencyDomainRepository competencyDomains, ICompetencyCatalogVersionRepository competencyVersions,
AiSettingsService aiSettings, IParticipationSessionRepository participationSessions)
{
_units = units; _lessons = lessons; _groups = groups;
_subjects = subjects; _competencyDomains = competencyDomains;
_competencyVersions = competencyVersions;
_aiSettings = aiSettings; _participationSessions = participationSessions;
}
@@ -102,6 +107,8 @@ public partial class PlanningTabViewModel : ObservableObject
GradeLevel = group?.GradeLevel ?? 0;
SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : "";
GroupLabel = group?.Name ?? "";
CompetencyCatalogVersionId = CompetencyCatalogVersionResolver.Resolve(
_competencyVersions, SubjectId, group?.CompetencyCatalogVersionId);
LoadUnits(preferActive: true);
}
@@ -577,6 +584,7 @@ public partial class UnitDialogViewModel : ObservableObject
private readonly Guid _groupId;
private readonly Guid? _subjectId;
private readonly int _gradeLevel;
private readonly Guid _competencyCatalogVersionId;
private readonly Unit? _editingUnit;
private readonly List<string> _competencyCodes;
@@ -620,10 +628,12 @@ public partial class UnitDialogViewModel : ObservableObject
}
public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains,
Guid groupId, Guid? subjectId, int gradeLevel, string groupName, string subjectName, Unit? editingUnit)
Guid groupId, Guid? subjectId, int gradeLevel, string groupName, string subjectName, Unit? editingUnit,
Guid competencyCatalogVersionId = default)
{
_units = units; _competencyDomains = competencyDomains;
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
_competencyCatalogVersionId = competencyCatalogVersionId;
_editingUnit = editingUnit;
GroupSubjectDisplay = string.IsNullOrWhiteSpace(subjectName)
? $"{groupName} · kein Fach hinterlegt (siehe Lerngruppe)" : $"{groupName} · {subjectName}";
@@ -646,7 +656,7 @@ public partial class UnitDialogViewModel : ObservableObject
CompetencyTagGroups.Clear();
if (!_subjectId.HasValue) return;
var selected = _competencyCodes.ToHashSet();
foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel))
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))
@@ -22,39 +22,97 @@ public partial class SettingsViewModel
[ObservableProperty] private SubjectListItem? _catalogSubject;
[ObservableProperty] private int _catalogGradeLevel = 10;
[ObservableProperty] private CompetencyCatalogVersion? _catalogVersion;
[ObservableProperty] private string _newDomainName = "";
[ObservableProperty] private string _newDomainCode = "";
[ObservableProperty] private string _catalogValidation = "";
[ObservableProperty] private string _newVersionName = "";
[ObservableProperty] private string _versionValidation = "";
public ObservableCollection<DomainEditItem> Domains { get; } = [];
public ObservableCollection<CompetencyCatalogVersion> CatalogVersions { get; } = [];
// ── Katalog: Laden ────────────────────────────────────────────────────────
partial void OnCatalogSubjectChanged(SubjectListItem? value) => LoadCatalog();
partial void OnCatalogSubjectChanged(SubjectListItem? value) => LoadVersions();
partial void OnCatalogGradeLevelChanged(int value) => LoadCatalog();
partial void OnCatalogVersionChanged(CompetencyCatalogVersion? value) => LoadCatalog();
private void LoadVersions()
{
var selectedId = CatalogVersion?.Id;
CatalogVersions.Clear();
if (CatalogSubject is null) { LoadCatalog(); return; }
foreach (var v in _versionRepo.GetBySubject(CatalogSubject.Id))
CatalogVersions.Add(v);
if (CatalogVersions.Count == 0)
CatalogVersions.Add(_versionRepo.GetOrCreateDefault(CatalogSubject.Id));
CatalogVersion = CatalogVersions.FirstOrDefault(v => v.Id == selectedId)
?? CatalogVersions.FirstOrDefault(v => v.IsDefault)
?? CatalogVersions.FirstOrDefault();
}
private void LoadCatalog()
{
Domains.Clear();
CatalogValidation = "";
if (CatalogSubject is null) return;
foreach (var d in _domainRepo.GetBySubjectAndGrade(CatalogSubject.Id, CatalogGradeLevel))
if (CatalogSubject is null || CatalogVersion is null) return;
foreach (var d in _domainRepo.GetBySubjectAndGrade(CatalogSubject.Id, CatalogGradeLevel, CatalogVersion.Id))
Domains.Add(CreateDomainEditItem(d));
RefreshDomainMoveState();
}
// ── Versionen (Bildungspläne) verwalten ──────────────────────────────────
[RelayCommand]
private void AddCatalogVersion()
{
if (CatalogSubject is null) { VersionValidation = "Bitte zuerst ein Fach auswählen."; return; }
if (string.IsNullOrWhiteSpace(NewVersionName)) { VersionValidation = "Name der Version erforderlich."; return; }
var version = new CompetencyCatalogVersion
{
SubjectId = CatalogSubject.Id, Name = NewVersionName.Trim(),
SortOrder = CatalogVersions.Count,
};
_versionRepo.Save(version);
CatalogVersions.Add(version);
CatalogVersion = version;
NewVersionName = ""; VersionValidation = "";
}
[RelayCommand]
private void SetCatalogVersionAsDefault(CompetencyCatalogVersion? version)
{
if (version is null || CatalogSubject is null) return;
version.IsDefault = true;
_versionRepo.Save(version);
LoadVersions();
}
[RelayCommand]
private void DeleteCatalogVersion(CompetencyCatalogVersion? version)
{
if (version is null) return;
try { _versionRepo.Delete(version.Id); }
catch (InvalidOperationException ex) { VersionValidation = ex.Message; return; }
LoadVersions();
}
// ── Katalog: Bereich hinzufügen / löschen ────────────────────────────────
[RelayCommand]
private void AddDomain()
{
if (CatalogSubject is null) { CatalogValidation = "Bitte zuerst ein Fach auswählen."; return; }
if (CatalogVersion is null) { CatalogValidation = "Bitte zuerst eine Version auswählen."; return; }
if (string.IsNullOrWhiteSpace(NewDomainName)) { CatalogValidation = "Bereichsname erforderlich."; return; }
var domain = new CompetencyDomain
{
SubjectId = CatalogSubject.Id,
GradeLevel = CatalogGradeLevel,
VersionId = CatalogVersion.Id,
Name = NewDomainName.Trim(),
Code = NewDomainCode.Trim(),
SortOrder = Domains.Count,
@@ -27,11 +27,16 @@ public partial class SettingsViewModel
CatalogValidation = "Bitte zuerst ein Fach auswählen.";
return null;
}
if (CatalogVersion is null)
{
CatalogValidation = "Bitte zuerst eine Version auswählen.";
return null;
}
try
{
CatalogValidation = "";
return _catalogImport.Analyze(
json, CatalogSubject.Id, CatalogSubject.Name, CatalogGradeLevel);
json, CatalogSubject.Id, CatalogSubject.Name, CatalogGradeLevel, CatalogVersion.Id);
}
catch (InvalidDataException ex)
{
@@ -71,9 +76,15 @@ public partial class SettingsViewModel
return null;
}
if (CatalogVersion is null)
{
CatalogValidation = "Bitte zuerst eine Version auswählen.";
return null;
}
CatalogValidation = "";
return _catalogImport.Analyze(SerializeCatalog(targetGradeLevel), CatalogSubject.Id,
CatalogSubject.Name, targetGradeLevel);
CatalogSubject.Name, targetGradeLevel, CatalogVersion.Id);
}
public void SetCatalogCopyStatus(int targetGradeLevel) =>
@@ -49,6 +49,7 @@ public partial class SettingsViewModel : ObservableObject
{
private readonly ISubjectRepository _subjects;
private readonly ICompetencyDomainRepository _domainRepo;
private readonly ICompetencyCatalogVersionRepository _versionRepo;
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
private readonly IGradingSchemeRepository _gradingSchemes;
private readonly GradingService _grading;
@@ -95,6 +96,7 @@ public partial class SettingsViewModel : ObservableObject
public TrashViewModel TrashTab { get; }
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
ICompetencyCatalogVersionRepository versionRepo,
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
GradingService grading, BackupService backups, BackupSettingsService backupSettings,
DatabaseEncryptionService dbEncryption,
@@ -123,6 +125,7 @@ public partial class SettingsViewModel : ObservableObject
TrashTab = trashTab;
_subjects = subjects;
_domainRepo = domainRepo;
_versionRepo = versionRepo;
_gradingKeyTemplates = gradingKeyTemplates;
_gradingSchemes = gradingSchemes;
_grading = grading;
@@ -39,6 +39,21 @@
HorizontalAlignment="Stretch"/>
</StackPanel>
<!-- Bildungsplan-Version: nur sichtbar, wenn das Fach bereits existiert und mehr als eine
Version hat (neue Bildungspläne wachsen klassenstufenweise hoch, siehe Settings/Kompetenzen) -->
<StackPanel Spacing="4" IsVisible="{Binding HasCompetencyVersionChoice}">
<TextBlock Text="Bildungsplan-Version" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding AvailableCompetencyVersions}"
SelectedItem="{Binding SelectedCompetencyVersion}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate DataType="vm:CompetencyVersionListItem">
<TextBlock Text="{Binding DisplayName}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Klassenstufe *" FontSize="12" Opacity="0.7"/>
@@ -8,6 +8,8 @@
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="3">
<TextBlock Text="Kompetenzübersicht" FontSize="18" FontWeight="SemiBold"/>
<TextBlock Text="{Binding VersionLabel, StringFormat='Bildungsplan-Version: {0}'}" FontSize="12" Opacity="0.65"
IsVisible="{Binding VersionLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding Summary}" FontSize="12" Opacity="0.65"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
@@ -245,13 +245,17 @@ public partial class GroupDetailView : UserControl
{
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
var competencyCatalogVersionId = CompetencyCatalogVersionResolver.Resolve(
App.Services.GetRequiredService<ICompetencyCatalogVersionRepository>(),
vm.Group.SubjectId, vm.Group.CompetencyCatalogVersionId);
var dialogVm = new ExamDialogViewModel(
App.Services.GetRequiredService<IExamRepository>(),
App.Services.GetRequiredService<ICompetencyDomainRepository>(),
App.Services.GetRequiredService<IGradingKeyTemplateRepository>(),
App.Services.GetRequiredService<GradingService>(),
groupId, vm.Group.SubjectId, vm.Group.GradeLevel, vm.Group.GradingSystem,
vm.SubjectName, vm.Group.IsDifferentiated, editingExam, duplicateSource);
vm.SubjectName, vm.Group.IsDifferentiated, editingExam, duplicateSource,
competencyCatalogVersionId);
var dialog = new ExamDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
@@ -54,7 +54,8 @@ public partial class PlanningTabView : UserControl
var dialogVm = new UnitDialogViewModel(
App.Services.GetRequiredService<IUnitRepository>(),
App.Services.GetRequiredService<ICompetencyDomainRepository>(),
groupId, vm.SubjectId, vm.GradeLevel, vm.GroupLabel, vm.SubjectName, editingUnit);
groupId, vm.SubjectId, vm.GradeLevel, vm.GroupLabel, vm.SubjectName, editingUnit,
vm.CompetencyCatalogVersionId);
var dialog = new UnitDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
xmlns:services="clr-namespace:LehrerApp.Core.Services;assembly=LehrerApp.Core"
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
x:Class="LehrerApp.Desktop.Views.Settings.SettingsView"
x:DataType="vm:SettingsViewModel">
@@ -112,8 +113,8 @@
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="640">
<!-- Fach + Klassenstufe Auswahl + Import/Export -->
<Grid ColumnDefinitions="*,12,100,12,Auto,8,Auto,8,Auto">
<!-- Fach + Klassenstufe + Bildungsplan-Version Auswahl + Import/Export -->
<Grid ColumnDefinitions="*,12,90,12,160,12,Auto,8,Auto,8,Auto">
<ComboBox Grid.Column="0"
ItemsSource="{Binding Subjects}"
SelectedItem="{Binding CatalogSubject}"
@@ -128,15 +129,63 @@
<NumericUpDown Grid.Column="2"
Value="{Binding CatalogGradeLevel}"
Minimum="1" Maximum="13" FormatString="0"/>
<Button Grid.Column="4" Content="JSON importieren" Click="OnImportClick"
<ComboBox Grid.Column="4"
ItemsSource="{Binding CatalogVersions}"
SelectedItem="{Binding CatalogVersion}"
IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"
HorizontalAlignment="Stretch"
ToolTip.Tip="Bildungsplan-Version dieses Katalogs">
<ComboBox.ItemTemplate>
<DataTemplate DataType="models:CompetencyCatalogVersion">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="6" Content="JSON importieren" Click="OnImportClick"
IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Grid.Column="6" Content="JSON exportieren" Click="OnExportClick"
<Button Grid.Column="8" Content="JSON exportieren" Click="OnExportClick"
IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Grid.Column="8" Content="Kopieren…" Click="OnCopyCatalogClick"
<Button Grid.Column="10" Content="Kopieren…" Click="OnCopyCatalogClick"
IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"
ToolTip.Tip="Katalog in eine andere Klassenstufe kopieren"/>
ToolTip.Tip="Katalog in eine andere Klassenstufe (derselben Version) kopieren"/>
</Grid>
<!-- Bildungsplan-Versionen verwalten -->
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="14,12"
IsVisible="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Spacing="10">
<TextBlock Text="Bildungsplan-Versionen" FontSize="12" FontWeight="SemiBold" Opacity="0.7"
ToolTip.Tip="Für schrittweise hochwachsende neue Bildungspläne: mehrere parallel gültige Kompetenzkataloge je Fach, je Kurs auswählbar."/>
<ItemsControl ItemsSource="{Binding CatalogVersions}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:CompetencyCatalogVersion">
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="Standard" FontSize="11" Opacity="0.6"
VerticalAlignment="Center" IsVisible="{Binding IsDefault}"/>
<Button Grid.Column="3" Content="Als Standard" FontSize="12" Padding="8,3"
IsVisible="{Binding !IsDefault}"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).SetCatalogVersionAsDefaultCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="5" Content="Löschen" FontSize="12" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).DeleteCatalogVersionCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,12,Auto">
<TextBox Grid.Column="0" Text="{Binding NewVersionName}"
PlaceholderText="Name der neuen Version (z.B. Bildungsplan 2026)"/>
<Button Grid.Column="2" Content="Version anlegen" Command="{Binding AddCatalogVersionCommand}"/>
</Grid>
<TextBlock Text="{Binding VersionValidation}" Foreground="Red" FontSize="12"
TextWrapping="Wrap"
IsVisible="{Binding VersionValidation, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
<!-- Katalog-Validierungsmeldung -->
<TextBlock Text="{Binding CatalogValidation}" Foreground="Red" FontSize="12"
IsVisible="{Binding CatalogValidation, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
+1
View File
@@ -146,6 +146,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
Simple<SupervisionDuty>(context => context.SupervisionDuties);
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
Simple<CompetencyDomain>(context => context.CompetencyDomains);
Simple<CompetencyCatalogVersion>(context => context.CompetencyCatalogVersions);
Simple<Vorgang>(context => context.Vorgaenge);
Simple<UntisHubJobState>(context => context.UntisHubJobStates);
@@ -16,7 +16,7 @@ public sealed class CompetencyCatalogImportServiceTests
var service = new CompetencyCatalogImportService(repository);
Assert.Throws<InvalidDataException>(() =>
service.Analyze("{ kaputt", SubjectId, "Mathematik", 7));
service.Analyze("{ kaputt", SubjectId, "Mathematik", 7, Guid.Empty));
Assert.False(repository.ReplaceWasCalled);
Assert.Equal("Alte Beschreibung", repository.Current.Single().Items.Single().Description);
@@ -27,7 +27,7 @@ public sealed class CompetencyCatalogImportServiceTests
{
var service = new CompetencyCatalogImportService(RepositoryWithExistingCatalog());
var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7);
var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7, Guid.Empty);
Assert.Equal(0, preview.NewDomains);
Assert.Equal(1, preview.NewCompetencies);
@@ -40,7 +40,7 @@ public sealed class CompetencyCatalogImportServiceTests
{
var repository = RepositoryWithExistingCatalog();
var service = new CompetencyCatalogImportService(repository);
var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7);
var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7, Guid.Empty);
service.Apply(preview, CompetencyCatalogImportMode.Merge);
@@ -55,7 +55,7 @@ public sealed class CompetencyCatalogImportServiceTests
{
var repository = RepositoryWithExistingCatalog();
var service = new CompetencyCatalogImportService(repository);
var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7);
var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7, Guid.Empty);
service.Apply(preview, CompetencyCatalogImportMode.Merge, new HashSet<string> { "item:A1" });
@@ -75,7 +75,7 @@ public sealed class CompetencyCatalogImportServiceTests
] }
] }
""";
var preview = service.Analyze(json, SubjectId, "Mathematik", 7);
var preview = service.Analyze(json, SubjectId, "Mathematik", 7, Guid.Empty);
service.Apply(preview, CompetencyCatalogImportMode.Merge);
@@ -89,7 +89,7 @@ public sealed class CompetencyCatalogImportServiceTests
{
var repository = RepositoryWithExistingCatalog();
var service = new CompetencyCatalogImportService(repository);
var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7);
var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7, Guid.Empty);
service.Apply(preview, CompetencyCatalogImportMode.Replace);
@@ -103,7 +103,7 @@ public sealed class CompetencyCatalogImportServiceTests
{
var repository = RepositoryWithExistingCatalog();
var service = new CompetencyCatalogImportService(repository);
var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7);
var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7, Guid.Empty);
repository.Current[0].UpdatedAt = repository.Current[0].UpdatedAt.AddSeconds(1);
var exception = Assert.Throws<InvalidOperationException>(() =>
@@ -121,7 +121,7 @@ public sealed class CompetencyCatalogImportServiceTests
.Replace("Mathematik", "Deutsch")
.Replace("\"gradeLevel\": 7", "\"gradeLevel\": 8");
var preview = service.Analyze(json, SubjectId, "Mathematik", 7);
var preview = service.Analyze(json, SubjectId, "Mathematik", 7, Guid.Empty);
Assert.Equal(2, preview.Warnings.Count);
}
@@ -138,7 +138,7 @@ public sealed class CompetencyCatalogImportServiceTests
""";
Assert.Throws<InvalidDataException>(() =>
service.Analyze(json, SubjectId, "Mathematik", 7));
service.Analyze(json, SubjectId, "Mathematik", 7, Guid.Empty));
}
private static InMemoryCompetencyDomainRepository RepositoryWithExistingCatalog() => new(
@@ -179,21 +179,22 @@ public sealed class CompetencyCatalogImportServiceTests
public List<CompetencyDomain> Current { get; private set; } = initial;
public bool ReplaceWasCalled { get; private set; }
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
Current.Where(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel).ToList();
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
Current.Where(x => x.SubjectId == subjectId).ToList();
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel, Guid versionId) =>
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 CompetencyDomain? GetById(Guid id) => Current.FirstOrDefault(x => x.Id == id);
public void Save(CompetencyDomain domain) => Current.Add(domain);
public void Delete(Guid id) => Current.RemoveAll(x => x.Id == id);
public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
Current.RemoveAll(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel);
public void DeleteBySubjectGradeAndVersion(Guid subjectId, int gradeLevel, Guid versionId) =>
Current.RemoveAll(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel && x.VersionId == versionId);
public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains)
public void ReplaceForSubjectGradeAndVersion(Guid subjectId, int gradeLevel, Guid versionId, List<CompetencyDomain> domains)
{
ReplaceWasCalled = true;
Current.RemoveAll(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel);
Current.RemoveAll(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel && x.VersionId == versionId);
foreach (var d in domains) { d.SubjectId = subjectId; d.GradeLevel = gradeLevel; d.VersionId = versionId; }
Current.AddRange(domains);
}
}
+29
View File
@@ -3601,6 +3601,35 @@ Format dokumentiert in [Kompetenzkatalog-KI-Prompt.md](docs/Kompetenzkatalog-KI-
der Kompetenzübersicht (Details siehe 11.4, "Kompetenzbericht"): druckt die Analyse für
den aktuell im Tab ausgewählten Schüler.
### 8.4 Bildungsplan-Versionierung (Nutzer-Nachtrag)
Neue Bildungspläne wachsen je Fach klassenstufenweise hoch — über mehrere Schuljahre hinweg gelten
alter und neuer Plan parallel, je nachdem, wann ein Kurs/eine Klasse eingeschult wurde.
- [x] **8.4.1** `CompetencyCatalogVersion` (Fach + Name + `IsDefault`) eingeführt;
`CompetencyDomain.VersionId` und `LearningGroup.CompetencyCatalogVersionId` (nullable = "nutzt
die Standardversion des Fachs") verknüpfen Katalog bzw. Kurs mit einer Version. Migration legt
für jedes Fach mit bestehendem Katalog automatisch eine Standardversion an, bestehende Kurse
bleiben unverändert nutzbar. Details siehe [Datenmodell.md](docs/Datenmodell.md).
- [x] **8.4.2** Versionen verwalten (anlegen, als Standard setzen, löschen — blockiert bei
Referenzen oder für die Standardversion) im Settings-Tab „Kompetenzen“, dort auch als dritte
Auswahl neben Fach/Klassenstufe zum Filtern des bearbeiteten Katalogs.
`CompetencyCatalogImportService` (JSON-Import/Kopieren zwischen Klassenstufen) ist
versionsscharf: Import/Kopieren wirkt immer nur innerhalb der gerade gewählten Version.
- [x] **8.4.3** Kursanlage/-bearbeitung: Bildungsplan-Version je Kurs auswählbar
(`AddGroupDialogViewModel`/`AddGroupDialog.axaml`), sobald das gewählte Fach mehr als eine
Version hat. Kompetenzen-Tab, Kompetenzauswahl in Unterrichtseinheiten und in Klausuraufgaben
sowie die Kompetenz-Sichtbarkeit im Mitarbeit-Tab zeigen jeweils den Katalog der für den Kurs
geltenden Version (`CompetencyCatalogVersionResolver.Resolve`).
- [x] **8.4.4** MCP: Bildungsplan-Versionen vollständig exponiert (`get_competency_plan_versions`,
`create_competency_plan_version`, `update_competency_plan_version`,
`delete_competency_plan_version`), `get_competency_catalog`/`create_competency_domain` um einen
optionalen `versionId`-Parameter erweitert (weglassen = Standardversion), sowie
`update_group_competency_version` zum Setzen der Version eines Kurses.
Bewusst nicht Teil dieses Nachtrags: TODO 16.5 (Kompetenz-Referenzen in Unit/Lesson/ExamTask von
Freitext-`Code` auf stabile `CompetencyItem.Id` umstellen) — unabhängiges, vorbestehendes Thema.
---
## 9. Dashboard
+32
View File
@@ -37,6 +37,38 @@ ein eigenes Feld wie `Student.SchoolEntryDate` zu verwenden.
Pro Kombination aus Schüler und Lerngruppe darf es höchstens eine Zuordnung
geben.
## Bildungsplan-Version (`CompetencyCatalogVersion`)
Bildet ab, dass für ein Fach mehrere Kompetenzkataloge parallel gültig sein können — typischerweise
weil ein neuer Bildungsplan klassenstufenweise hochwächst (z.B. beginnend in Klasse 5, ein Jahrgang
pro Schuljahr) und deshalb über mehrere Schuljahre hinweg sowohl der alte als auch der neue Plan
gleichzeitig unterrichtet werden.
- `SubjectId`: Fach, zu dem die Version gehört
- `Name`: frei gewählte Bezeichnung, z.B. `Bildungsplan 2016` oder `Bildungsplan 2026`
- `IsDefault`: je Fach höchstens eine Version — Kurse ohne explizite Wahl (siehe
`LearningGroup.CompetencyCatalogVersionId`) nutzen sie. `CompetencyCatalogVersionRepository.Save`
nimmt beim Setzen automatisch die bisherige Standardversion desselben Fachs zurück.
`CompetencyDomain.VersionId` verweist auf die Version, zu der ein Kompetenzbereich gehört — derselbe
Katalog-Slot (Fach + Klassenstufe) kann also mehrfach existieren, einmal je Version.
`LearningGroup.CompetencyCatalogVersionId` (`Guid?`) wählt je Kurs/Klasse, welche Version gilt.
Analog zum Verhältnis von `GroupMembership` zu `LearningGroup.SchoolYear` (siehe oben) ist dieses
Feld bewusst nullable mit einer Sonderbedeutung: `null` heißt nicht "keine Version", sondern "die
Standardversion des Fachs" — so brauchen bestehende Kurse und Kompetenzkataloge beim Einführen der
Versionierung keine Migration außer dem einmaligen Anlegen je einer Standardversion pro Fach
(`LiteDbContext.MigrateCompetencyCatalogVersions`, Schema-Version 5).
`CompetencyCatalogVersionRepository.Delete` blockiert (statt in den Papierkorb zu verschieben, siehe
14.2), wenn die Version noch von einem Kompetenzbereich oder einem Kurs referenziert wird, oder wenn
sie die Standardversion ihres Fachs ist — erst eine andere Version des Fachs muss zum Standard
werden, damit nie ein Fach ganz ohne Standardversion dasteht.
Bewusst **nicht** Teil dieser Versionierung: die Verknüpfung von Kompetenzcodes zu Unterrichtseinheiten,
Einzelstunden und Klausuraufgaben (`Unit.Competencies`, `Lesson.Competencies`, `ExamTask.CompetencyCodes`)
bleibt Freitext-Code-basiert, nicht auf `CompetencyItem.Id` umgestellt — ein vorbestehendes, unabhängiges
Thema (siehe TODO 16.5).
## Gewichtungsschema (`GradingScheme`)
Legt die prozentuale Gewichtung von Klausuren, Mitarbeit und sonstigen Leistungen für die