Compare commits
15
Commits
d1f3dd5148
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb38b6bb04 | ||
|
|
cbcb739097 | ||
|
|
3f7c055489 | ||
|
|
d61a97de1e | ||
|
|
41649b0a05 | ||
|
|
651c9cddf8 | ||
|
|
10921deb34 | ||
|
|
e2f019667a | ||
|
|
336869e75f | ||
|
|
97eb41a0ea | ||
|
|
a3cd57e5d8 | ||
|
|
e4a81e8974 | ||
|
|
24a30881f3 | ||
|
|
2293ddb84d | ||
|
|
702694d4f4 |
No files matched your search
@@ -42,6 +42,13 @@ The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from th
|
||||
|
||||
### Project layout
|
||||
|
||||
The PHP backend has lived in the independent
|
||||
[LehrerBackend repository](https://git.science-teaching.de/admin/LehrerBackend) since
|
||||
2026-10-19 (local sibling `../LehrerBackend/`). `ai-backend/README.md` is only a pointer;
|
||||
change PHP endpoints, providers, authentication and billing in LehrerBackend. Older
|
||||
`ai-backend/...` paths in comments/TODO refer to the new repository root. Desktop clients and
|
||||
DTOs stay here; no submodule or local backend checkout is needed to build the app.
|
||||
|
||||
- **LehrerApp.Core** — domain models (`Models/`), repository interfaces (`Interfaces/IRepositories.cs`),
|
||||
and framework-free business services (`Services/`): `GradingService` (rounding, grading-key
|
||||
validation, report-grade calculation), `SchoolYearService`, `AppLogger`, `BackupService`,
|
||||
|
||||
@@ -32,6 +32,10 @@ public class Exam
|
||||
/// erste Arbeit (<see cref="RepeatOfExamId"/>) versäumt haben. Anders als eine Wiederholung
|
||||
/// wird sie nur von diesen Schülern geschrieben und ersetzt deren fehlende Note.</summary>
|
||||
public bool IsMakeUp { get; set; }
|
||||
/// <summary>Förderarbeit: inhaltlich eigene Arbeit, die einzelne Schüler statt der verknüpften
|
||||
/// ersten Arbeit schreiben — typischerweise am selben Tag, daher darf sie am Datum der ersten
|
||||
/// Arbeit liegen. Zählt wie eine Nachschreibarbeit (ersetzt nur eine fehlende Note).</summary>
|
||||
public bool IsSupportWork { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -78,6 +82,13 @@ public enum RepeatGradeRule { Better, Repeat, Original }
|
||||
/// <summary>Reihenfolge ist persistiert (LiteDB speichert den Enum-Wert) — nur hinten anhängen.</summary>
|
||||
public enum ExamKind { Klausur, Test, KontrollierteHausaufgabe, Sonstiges }
|
||||
|
||||
public static class ExamFollowUpExtensions
|
||||
{
|
||||
/// <summary>Nachschreib- und Förderarbeiten sind inhaltlich eigene Arbeiten nur für einzelne
|
||||
/// Schüler: ihre Note ersetzt eine fehlende Note der ersten Arbeit, verdrängt sie aber nie.</summary>
|
||||
public static bool ReplacesMissingGrade(this Exam exam) => exam.IsMakeUp || exam.IsSupportWork;
|
||||
}
|
||||
|
||||
public static class ExamKindExtensions
|
||||
{
|
||||
public static bool CountsAsWritten(this ExamKind kind) => kind == ExamKind.Klausur;
|
||||
|
||||
@@ -68,6 +68,11 @@ public class GroupMembership
|
||||
public DateOnly? JoinedAt { get; set; }
|
||||
public DateOnly? LeftAt { get; set; }
|
||||
public Niveau? Niveau { get; set; }
|
||||
/// <summary>Förderschüler-Markierung, unabhängig von der Niveaudifferenzierung der Gruppe
|
||||
/// (<see cref="LearningGroup.IsDifferentiated"/>): auch in einem nicht differenzierten Kurs
|
||||
/// bekommen einzelne Schüler eigenes Fördermaterial. Auswertung siehe
|
||||
/// <see cref="Services.GroupMembershipService.IsSupportStudent"/>.</summary>
|
||||
public bool IsSupportStudent { get; set; }
|
||||
}
|
||||
public enum MembershipPeriod { FullYear, H1Only, H2Only, Custom }
|
||||
public enum GroupType { Class, Course }
|
||||
|
||||
@@ -71,6 +71,7 @@ public enum UntisCacheKind { Absences, ClassRegister, Roster }
|
||||
/// </summary>
|
||||
public class UntisCacheFetchState
|
||||
{
|
||||
public int AbsenceReportVersion { get; set; }
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public UntisCacheKind Kind { get; set; }
|
||||
|
||||
@@ -37,12 +37,12 @@ public static class ExamCoverSheetService
|
||||
/// <summary>Wer eine Arbeit mitschreibt — eine Regel für Punkteeingabe und Deckblätter.</summary>
|
||||
public static class ExamParticipantService
|
||||
{
|
||||
/// <summary>Bei einer Nachschreibarbeit: Schüler, die die erste Arbeit bereits mit Note
|
||||
/// geschrieben haben. Sonst leer.</summary>
|
||||
/// <summary>Bei einer Nachschreib- oder Förderarbeit: Schüler, die die erste Arbeit bereits mit
|
||||
/// Note geschrieben haben. Sonst leer.</summary>
|
||||
public static HashSet<Guid> WroteOriginal(Exam exam, Func<Guid, List<ExamResult>> resultsByExam)
|
||||
{
|
||||
var wroteOriginal = new HashSet<Guid>();
|
||||
if (exam.IsMakeUp && exam.RepeatOfExamId is { } originalId)
|
||||
if (exam.ReplacesMissingGrade() && exam.RepeatOfExamId is { } originalId)
|
||||
foreach (var r in resultsByExam(originalId).Where(r => !r.Absent && r.Grade is not null))
|
||||
wroteOriginal.Add(r.StudentId);
|
||||
return wroteOriginal;
|
||||
@@ -54,7 +54,9 @@ public static class ExamParticipantService
|
||||
// Niveau-Klausur: nur Schüler mit passendem Niveau. Klausuren ohne Niveau-Zuordnung gelten
|
||||
// weiterhin für die ganze Gruppe.
|
||||
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) return false;
|
||||
// Nachschreibarbeit: wer die erste Arbeit schon mit Note geschrieben hat, taucht nicht auf —
|
||||
// Förderarbeit: nur Förderschüler (außer es gibt hier bereits ein Ergebnis).
|
||||
if (exam.IsSupportWork && !hasResult && !GroupMembershipService.IsSupportStudent(membership)) return false;
|
||||
// Nachschreib-/Förderarbeit: wer die erste Arbeit schon mit Note geschrieben hat, taucht nicht auf —
|
||||
// außer es gibt hier bereits ein Ergebnis (nichts verstecken, was schon eingetragen wurde).
|
||||
return hasResult || !wroteOriginal;
|
||||
}
|
||||
|
||||
@@ -45,10 +45,10 @@ public static class ExamScoringService
|
||||
AppliesTo(task, variant) && index < points.Count ? points[index] : 0).Sum();
|
||||
|
||||
/// <summary>
|
||||
/// Wählt pro Arbeit samt allen verknüpften Folgearbeiten (Wiederholung, Nachschreibarbeit)
|
||||
/// Wählt pro Arbeit samt allen verknüpften Folgearbeiten (Wiederholung, Nachschreib-/Förderarbeit)
|
||||
/// genau eine Note. Andere Arbeiten bleiben erhalten. Fehlt die erste Arbeit (Schüler war
|
||||
/// abwesend), zählt die Nachschreibarbeit allein; hat der Schüler beide geschrieben, zählt bei
|
||||
/// einer Nachschreibarbeit immer die erste — sie ersetzt nur eine fehlende Note.
|
||||
/// abwesend), zählt die Nachschreib-/Förderarbeit allein; hat der Schüler beide geschrieben,
|
||||
/// zählt immer die erste — sie ersetzen nur eine fehlende Note.
|
||||
/// </summary>
|
||||
public static List<string> EffectiveGrades(IEnumerable<(Exam Exam, string Grade)> entries,
|
||||
GradingSystem system)
|
||||
@@ -72,7 +72,7 @@ public static class ExamScoringService
|
||||
var current = ordered[0].Grade;
|
||||
foreach (var next in ordered.Skip(1))
|
||||
current = Choose(current, next.Grade,
|
||||
next.Exam.IsMakeUp ? RepeatGradeRule.Original : next.Exam.RepeatGradeRule, system);
|
||||
next.Exam.ReplacesMissingGrade() ? RepeatGradeRule.Original : next.Exam.RepeatGradeRule, system);
|
||||
result.Add(current);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -10,6 +10,11 @@ public enum SchoolYearPeriodKind { FullYear, H1, H2 }
|
||||
/// </summary>
|
||||
public static class GroupMembershipService
|
||||
{
|
||||
/// <summary>Förderschüler ist, wer markiert ist oder in einer differenzierten Gruppe das
|
||||
/// Förderniveau hat.</summary>
|
||||
public static bool IsSupportStudent(GroupMembership? membership) =>
|
||||
membership is { } m && (m.IsSupportStudent || m.Niveau == Niveau.Foerder);
|
||||
|
||||
public static bool IsActiveOn(GroupMembership membership, DateOnly date)
|
||||
{
|
||||
if (membership.JoinedAt is { } joinedAt && date < joinedAt) return false;
|
||||
|
||||
@@ -80,6 +80,7 @@ public sealed class GroupRolloverService(
|
||||
JoinedAt = targetStart,
|
||||
LeftAt = null,
|
||||
Niveau = membership.Niveau,
|
||||
IsSupportStudent = membership.IsSupportStudent,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,38 @@ namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class ClassTeacherViewModelsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Früher gegangen", 2, 90)]
|
||||
[InlineData("Vorzeitig verlassen", 0, 20)]
|
||||
public void FruehesGehen_IstTeilfehlzeitUndImKalenderGesondertErkennbar(string reason, int periods, int minutes)
|
||||
{
|
||||
var date = new DateOnly(2026, 9, 3);
|
||||
var row = new ClassAbsenceDaySummaryRow(date, "Müller Ada", 1, periods, minutes,
|
||||
[], [], ["nicht entsch."], [reason], null, null, true);
|
||||
|
||||
Assert.True(row.IsEarlyDeparture);
|
||||
Assert.False(row.CountsAsFullDay);
|
||||
Assert.False(row.IsLate);
|
||||
Assert.True(row.IsPartialAbsence);
|
||||
var marker = Assert.Single(ClassTeacherDetailsViewModel.BuildDayEvents(date, [row], [], false));
|
||||
Assert.Equal("G", marker.Code);
|
||||
Assert.Equal("#C62828", marker.ColorHex);
|
||||
Assert.Contains("Früher gegangen", marker.Tooltip);
|
||||
Assert.Contains("Unentschuldigt", marker.Tooltip);
|
||||
Assert.DoesNotContain("Ganzer Fehltag", marker.Tooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kalender_GanzeFehltageBehaltenEigenesKuerzel()
|
||||
{
|
||||
var date = new DateOnly(2026, 9, 3);
|
||||
var row = new ClassAbsenceDaySummaryRow(date, "Müller Ada", 1, 6, 270,
|
||||
[], [], ["nicht entsch."], ["Absent"], null, null, true);
|
||||
var marker = Assert.Single(ClassTeacherDetailsViewModel.BuildDayEvents(date, [row], [], false));
|
||||
Assert.Equal("U", marker.Code);
|
||||
Assert.Contains("Ganzer Fehltag", marker.Tooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Monatskalender_KodiertFehlzeitenUndBlendetHausaufgabenStandardmaessigAus()
|
||||
{
|
||||
@@ -32,7 +64,7 @@ public sealed class ClassTeacherViewModelsTests
|
||||
|
||||
Assert.Equal(0, days.Count % 5);
|
||||
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "V");
|
||||
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "U");
|
||||
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "T");
|
||||
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "!");
|
||||
Assert.DoesNotContain(days.SelectMany(d => d.Events), e => e.Code == "H");
|
||||
|
||||
@@ -65,10 +97,10 @@ public sealed class ClassTeacherViewModelsTests
|
||||
Assert.Equal(30, days.Count);
|
||||
Assert.False(days[0].HasSignal);
|
||||
Assert.Equal("1", days[0].DayNumber);
|
||||
Assert.Equal("U", days[2].SignalCode); // stärker als der parallele Klassenbucheintrag
|
||||
Assert.Equal("T", days[2].SignalCode); // stärker als der parallele Klassenbucheintrag
|
||||
Assert.True(days[2].IsToday);
|
||||
Assert.Contains("Ada Müller", days[2].Tooltip);
|
||||
Assert.Equal("E", days[3].SignalCode);
|
||||
Assert.Equal("T", days[3].SignalCode);
|
||||
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet
|
||||
}
|
||||
|
||||
@@ -87,7 +119,7 @@ public sealed class ClassTeacherViewModelsTests
|
||||
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, [], month,
|
||||
"Ada Müller");
|
||||
|
||||
Assert.Equal("U", days[2].SignalCode);
|
||||
Assert.Equal("T", days[2].SignalCode);
|
||||
Assert.False(days[3].HasSignal);
|
||||
}
|
||||
|
||||
@@ -107,7 +139,7 @@ public sealed class ClassTeacherViewModelsTests
|
||||
|
||||
Assert.True(drawing.ContentHeight > 0);
|
||||
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "Ada Müller");
|
||||
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "U");
|
||||
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "T");
|
||||
Assert.DoesNotContain(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "E");
|
||||
}
|
||||
|
||||
@@ -367,6 +399,24 @@ public sealed class ClassTeacherViewModelsTests
|
||||
Assert.False(andere.CountsAsFullDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupByStudentAndDay_ZweiFehlstundenBleibenTeilfehlzeit()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", null,
|
||||
null, 1, null, true, null, 3, "nicht entsch.", false),
|
||||
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "PAN", "Kunst", null,
|
||||
null, 2, null, true, null, 4, "nicht entsch.", false),
|
||||
};
|
||||
|
||||
var row = Assert.Single(ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries));
|
||||
|
||||
Assert.False(row.CountsAsFullDay);
|
||||
Assert.True(row.IsPartialAbsence);
|
||||
Assert.Equal("2 Fehlstunden", row.KindLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupByStudentAndDay_TrenntGleichenNamenAnUnterschiedlichenTagen()
|
||||
{
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed class ExamCoverSheetTests : IDisposable
|
||||
]);
|
||||
|
||||
var texts = drawing.Commands.OfType<DrawStringEx>().Select(x => x.Text).ToList();
|
||||
Assert.Equal(["Aufgabe", "Max. Punktzahl", "Punktzahl", "Nr. 1", "5", "4,5", "+3", "1"], texts);
|
||||
Assert.Equal(["Aufgabe", "Max. Punktzahl", "Punktzahl", "Nr. 1", "5", "4,5", "Bonus", "+3", "1"], texts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -120,7 +120,7 @@ public sealed class ExamCoverSheetTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_SchreibtEinSammelPdfMitEinerSeiteJeSchueler()
|
||||
public async Task Generate_SchreibtEinSammelPdfMitEinerSeiteJeSchueler()
|
||||
{
|
||||
_results.Add(new ExamResult
|
||||
{ ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 });
|
||||
@@ -129,7 +129,7 @@ public sealed class ExamCoverSheetTests : IDisposable
|
||||
var vm = Build([store]);
|
||||
var output = Path.Combine(_directory, "deckblaetter.pdf");
|
||||
|
||||
Assert.True(vm.Generate(output));
|
||||
Assert.True(await vm.GenerateAsync(output));
|
||||
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
|
||||
Assert.Equal(2, document.NumberOfPages);
|
||||
@@ -145,10 +145,12 @@ public sealed class ExamCoverSheetTests : IDisposable
|
||||
// Ben ist noch nicht korrigiert: Linien zum Ausfüllen statt Punkten und Note.
|
||||
Assert.Contains($"Gesamtpunkte:{ExamCoverPlaceholderBuilder.BlankPoints}von17Punkte", ben);
|
||||
Assert.Contains("1 davon ohne Punkte und Note", vm.StatusMessage);
|
||||
Assert.False(vm.IsGenerating);
|
||||
Assert.True(vm.CanGenerate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_OhnePunkteEintragen_BleibtAuchBeiKorrigiertenBlanko()
|
||||
public async Task Generate_OhnePunkteEintragen_BleibtAuchBeiKorrigiertenBlanko()
|
||||
{
|
||||
_results.Add(new ExamResult
|
||||
{ ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 });
|
||||
@@ -158,21 +160,21 @@ public sealed class ExamCoverSheetTests : IDisposable
|
||||
vm.FillResults = false;
|
||||
var output = Path.Combine(_directory, "blanko.pdf");
|
||||
|
||||
Assert.True(vm.Generate(output));
|
||||
Assert.True(await vm.GenerateAsync(output));
|
||||
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
|
||||
Assert.DoesNotContain("17,5", Normalize(document.GetPage(1).Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_ZuVieleAufgabenFuerEineSeite_WarntImStatus()
|
||||
public async Task Generate_ZuVieleAufgabenFuerEineSeite_WarntImStatus()
|
||||
{
|
||||
_exam.Tasks = Enumerable.Range(1, 80).Select(n => new ExamTask { Nr = n, MaxPoints = 1 }).ToList();
|
||||
var store = Store();
|
||||
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
|
||||
var vm = Build([store]);
|
||||
|
||||
Assert.True(vm.Generate(Path.Combine(_directory, "lang.pdf")));
|
||||
Assert.True(await vm.GenerateAsync(Path.Combine(_directory, "lang.pdf")));
|
||||
|
||||
Assert.Contains("passen nicht auf eine Seite", vm.StatusMessage);
|
||||
}
|
||||
@@ -204,6 +206,35 @@ public sealed class ExamCoverSheetTests : IDisposable
|
||||
Assert.Equal(new TextValue("G-Kurs"), Values(Niveau.E)["Exam.Course"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Platzhalter_TitelAusDemDialog_LeerNimmtDenKlausurtitel()
|
||||
{
|
||||
var store = Store();
|
||||
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
|
||||
var template = store.Load(store.GetTemplates().Single());
|
||||
|
||||
Dictionary<string, PlaceholderValue> Values(string? title) => ExamCoverPlaceholderBuilder.Build(
|
||||
_exam, _group, "Chemie", _anna, null, false, new GradingService(), template, new DateOnly(2026, 1, 1),
|
||||
title: title);
|
||||
|
||||
Assert.Equal(new TextValue("Klassenarbeit Nr. 1"), Values(" Klassenarbeit Nr. 1 ")["Exam.Title"]);
|
||||
Assert.Equal(new TextValue("Atombau und PSE"), Values(" ")["Exam.Title"]);
|
||||
Assert.Equal(new TextValue("Atombau und PSE"), Values(null)["Exam.Title"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dialog_TitelIstMitDemKlausurtitelVorbelegt()
|
||||
{
|
||||
var store = Store();
|
||||
Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
|
||||
|
||||
var vm = Build([store]);
|
||||
|
||||
Assert.Equal("Atombau und PSE", vm.CoverTitle);
|
||||
vm.CoverTitle = "Klassenarbeit Nr. 1";
|
||||
Assert.Equal("Atombau und PSE", _exam.Title);
|
||||
}
|
||||
|
||||
// Der Vorlagen-Designer bietet genau diesen Katalog an - liefert die Hauptapp einen Namen
|
||||
// nicht oder mit anderem Typ, scheitert die Vorlage erst beim Drucken.
|
||||
[Fact]
|
||||
@@ -231,6 +262,54 @@ public sealed class ExamCoverSheetTests : IDisposable
|
||||
Assert.Equal("HED", new TeacherSettingsService(_directory).LoadAbbreviation());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RepeatGradeRule.Better, "bessere Note")]
|
||||
[InlineData(RepeatGradeRule.Repeat, "Note der Wiederholungsarbeit")]
|
||||
[InlineData(RepeatGradeRule.Original, "Note der ersten Arbeit")]
|
||||
public void Wiederholungsbaustein_ZeigtDieGespeicherteBewertungsregel(RepeatGradeRule rule, string expected)
|
||||
{
|
||||
var original = new Exam { GroupId = _group.Id, Title = "Erste Arbeit", Date = _exam.Date.AddDays(-14) };
|
||||
_exam.RepeatOfExamId = original.Id; _exam.RepeatGradeRule = rule;
|
||||
var template = new LoadedTemplate(new TemplateManifest(), new LayoutParser().Parse(
|
||||
"PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 240 $Exam.RepeatInfo fit=content"), new Dictionary<string, byte[]>());
|
||||
var drawing = GradeReportDrawingBuilder.Repeat(_exam, original, new ExamResult { Grade = "4" }, template);
|
||||
var text = string.Join(" ", drawing.Commands.OfType<DrawStringEx>().Select(c => c.Text));
|
||||
Assert.Contains(expected, text);
|
||||
Assert.Contains("Note der ersten Arbeit: 4", text);
|
||||
_exam.IsMakeUp = true;
|
||||
text = string.Join(" ", GradeReportDrawingBuilder.Repeat(_exam, original, null, template).Commands.OfType<DrawStringEx>().Select(c => c.Text));
|
||||
Assert.Contains("Nachschreibarbeit", text);
|
||||
Assert.Contains("ersetzt die versäumte", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Zwischenstandsbaustein_VerlangtDurchgangUndAlleAusgewaehltenSchueler()
|
||||
{
|
||||
var store = Store();
|
||||
var source = Path.Combine(_directory, "zwischenstand.lavorlage");
|
||||
TemplatePackage.Create(source, new TemplateManifest { Id = "zwischenstand", Name = "Zwischenstand",
|
||||
Metadata = new() { [TemplateMetadataKeys.ReportType] = TemplateReportTypes.ExamCover },
|
||||
Placeholders = [new(ReportTextDrawing.InterimGrades, PlaceholderType.Drawing)] },
|
||||
"PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 240 $Student.InterimGrades fit=content", new Dictionary<string, byte[]>());
|
||||
store.Import(source);
|
||||
var interims = new FakeInterimGrades();
|
||||
interims.Save(new() { StudentId = _anna.Id, GroupId = _group.Id, Date = _exam.Date, Occasion = "Sprechtag", OverallValue = "2" });
|
||||
var vm = new ExamCoverSheetDialogViewModel(_exam, _group, "Chemie", new FakeStudents([_anna, _ben]),
|
||||
new FakeMemberships([]), _results, new GradingService(), [store], new QuestTemplateRenderer(), interims: interims);
|
||||
var output = Path.Combine(_directory, "zwischenstand.pdf");
|
||||
Assert.True(vm.UsesInterimGrades);
|
||||
Assert.False(await vm.GenerateAsync(output));
|
||||
Assert.Contains("auswählen", vm.StatusMessage);
|
||||
vm.SelectedInterimRun = vm.InterimRuns.Single();
|
||||
Assert.False(await vm.GenerateAsync(output));
|
||||
Assert.Contains(_ben.FullName, vm.StatusMessage);
|
||||
Assert.False(File.Exists(output));
|
||||
vm.Students.Single(s => s.Model.Id == _ben.Id).IsIncluded = false;
|
||||
Assert.True(await vm.GenerateAsync(output));
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(output);
|
||||
Assert.Contains("Sprechtag", document.GetPage(1).Text);
|
||||
}
|
||||
|
||||
private static string Normalize(string text) => text.Replace(" ", "");
|
||||
private static List<float> Widths(DrawingValue drawing) =>
|
||||
drawing.Commands.OfType<DrawRectangle>().Select(r => r.Width).Distinct().Order().ToList();
|
||||
|
||||
@@ -84,4 +84,81 @@ public sealed class ExamDialogViewModelTests
|
||||
Assert.Equal(10, result.TotalPoints);
|
||||
Assert.Equal("1", result.Grade);
|
||||
}
|
||||
|
||||
// ── Förderarbeit ─────────────────────────────────────────────────────────
|
||||
|
||||
private static (ExamDialogViewModel Vm, Exam Original) BuildFollowUpViewModel(DateOnly originalDate)
|
||||
{
|
||||
var original = new Exam { GroupId = Guid.NewGuid(), Title = "Atombau", Date = originalDate };
|
||||
var vm = new ExamDialogViewModel(new FakeExams([original]), new FakeCompetencyDomains(),
|
||||
new FakeGradingKeyTemplates(), new GradingService(), original.GroupId, null, 9,
|
||||
GradingSystem.Grades1To6, "Chemie", false, null, null);
|
||||
vm.Title = "Atombau (Förder)";
|
||||
vm.Tasks.Add(new ExamTaskEditItem("A1", 10, 1, [], []) { OnChanged = () => { } });
|
||||
vm.Tasks[0].MaxPoints = 10;
|
||||
vm.SelectedOriginalExam = original;
|
||||
return (vm, original);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Foerderarbeit_UebernimmtDatumDerErstenArbeitUndDarfAmSelbenTagLiegen()
|
||||
{
|
||||
var (vm, original) = BuildFollowUpViewModel(new DateOnly(2026, 10, 14));
|
||||
|
||||
vm.FollowUpKindName = "Förderarbeit";
|
||||
|
||||
Assert.Equal("14.10.2026", vm.DateText);
|
||||
vm.SaveCommand.Execute(null);
|
||||
Assert.Equal("", vm.RepeatError);
|
||||
Assert.NotNull(vm.Result);
|
||||
Assert.True(vm.Result!.IsSupportWork);
|
||||
Assert.False(vm.Result.IsMakeUp);
|
||||
Assert.Equal(original.Id, vm.Result.RepeatOfExamId);
|
||||
Assert.Equal(original.Date, vm.Result.Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Foerderarbeit_VorDerErstenArbeit_WirdAbgelehnt()
|
||||
{
|
||||
var (vm, _) = BuildFollowUpViewModel(new DateOnly(2026, 10, 14));
|
||||
vm.FollowUpKindName = "Förderarbeit";
|
||||
|
||||
vm.DateText = "13.10.2026";
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Contains("Förderarbeit", vm.RepeatError);
|
||||
Assert.Null(vm.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nachschreibarbeit_AmSelbenTag_BleibtAbgelehnt()
|
||||
{
|
||||
var (vm, _) = BuildFollowUpViewModel(new DateOnly(2026, 10, 14));
|
||||
vm.FollowUpKindName = "Nachschreibarbeit";
|
||||
|
||||
vm.DateText = "14.10.2026";
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Contains("Nachschreibarbeit", vm.RepeatError);
|
||||
Assert.Null(vm.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Foerderarbeit_WirdBeimBearbeitenWiederhergestelltOhneDatumZuUeberschreiben()
|
||||
{
|
||||
var original = new Exam { GroupId = Guid.NewGuid(), Title = "Atombau", Date = new DateOnly(2026, 10, 14) };
|
||||
var support = new Exam
|
||||
{
|
||||
GroupId = original.GroupId, Title = "Förder", Date = new DateOnly(2026, 10, 16),
|
||||
RepeatOfExamId = original.Id, IsSupportWork = true,
|
||||
};
|
||||
|
||||
var vm = new ExamDialogViewModel(new FakeExams([original, support]), new FakeCompetencyDomains(),
|
||||
new FakeGradingKeyTemplates(), new GradingService(), original.GroupId, null, 9,
|
||||
GradingSystem.Grades1To6, "Chemie", false, support, null);
|
||||
|
||||
Assert.True(vm.IsSupportWork);
|
||||
Assert.Equal("16.10.2026", vm.DateText);
|
||||
Assert.False(vm.ShowRepeatRule);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
@@ -133,4 +134,95 @@ public sealed class ExamGradingDialogViewModelTests
|
||||
|
||||
Assert.Equal(["Aufgabe 1", "★ Bonus", "2. Redox"], vm.TaskHeaders);
|
||||
}
|
||||
|
||||
private sealed class RecordingResults : FakeResults, IExamResultRepository
|
||||
{
|
||||
public List<ExamResult> Saved { get; } = [];
|
||||
public new void Save(ExamResult result) => Saved.Add(result);
|
||||
}
|
||||
|
||||
private static (ExamGradingDialogViewModel Vm, RecordingResults Results) CreateRecordingGrid(
|
||||
ExamResult? existing = null)
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var student = new Student { FirstName = "Ben", LastName = "Cordes" };
|
||||
var exam = new Exam
|
||||
{
|
||||
GroupId = group.Id, Title = "Klausur 1", Tasks = [.. ThreeTasks()],
|
||||
GradingKey = GradingService.DefaultKey1To6(),
|
||||
};
|
||||
var results = new RecordingResults();
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.ExamId = exam.Id; existing.StudentId = student.Id;
|
||||
results.Add(existing);
|
||||
}
|
||||
var vm = new ExamGradingDialogViewModel(results, new FakeStudents([student]),
|
||||
new FakeMemberships([new GroupMembership { GroupId = group.Id, StudentId = student.Id }]),
|
||||
new GradingService(), exam, group.Id);
|
||||
return (vm, results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NurAngefahreneZelle_LegtKeinErgebnisMitNullPunktenAn()
|
||||
{
|
||||
var (vm, results) = CreateRecordingGrid();
|
||||
|
||||
// Fokus in die Zelle und wieder heraus ohne Eingabe: die View meldet das als TrySetValue(null).
|
||||
vm.Rows[0].Cells[0].TrySetValue(null);
|
||||
vm.Rows[0].Cells[1].TrySetValue(null);
|
||||
|
||||
Assert.Empty(results.Saved);
|
||||
Assert.Equal("", vm.Rows[0].GradeDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErsterEingetragenerWert_LegtDasErgebnisAn()
|
||||
{
|
||||
var (vm, results) = CreateRecordingGrid();
|
||||
|
||||
vm.Rows[0].Cells[0].TrySetValue(4);
|
||||
|
||||
var saved = Assert.Single(results.Saved);
|
||||
Assert.Equal([4, 0, 0], saved.Points);
|
||||
Assert.Equal(4, saved.TotalPoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnveraenderterWert_SpeichertNichtErneut()
|
||||
{
|
||||
var (vm, results) = CreateRecordingGrid();
|
||||
vm.Rows[0].Cells[0].TrySetValue(4);
|
||||
|
||||
vm.Rows[0].Cells[0].TrySetValue(4);
|
||||
|
||||
Assert.Single(results.Saved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeereZeileMitBestehendemErgebnis_WirdAlsUnkorrigiertGespeichertNichtAlsNull()
|
||||
{
|
||||
var (vm, results) = CreateRecordingGrid(
|
||||
new ExamResult { Points = [4], TotalPoints = 4, Grade = "4" });
|
||||
|
||||
vm.Rows[0].Cells[0].TrySetValue(null);
|
||||
|
||||
var saved = Assert.Single(results.Saved);
|
||||
Assert.Empty(saved.Points);
|
||||
Assert.Null(saved.Grade);
|
||||
Assert.False(ExamCoverSheetService.IsCorrected(saved));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbwesendheitOhnePunkte_WirdOhnePunkteUndNoteGespeichert()
|
||||
{
|
||||
var (vm, results) = CreateRecordingGrid();
|
||||
|
||||
vm.Rows[0].Absent = true;
|
||||
|
||||
var saved = Assert.Single(results.Saved);
|
||||
Assert.True(saved.Absent);
|
||||
Assert.Empty(saved.Points);
|
||||
Assert.Null(saved.Grade);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,43 @@ namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class LessonDialogViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Save_PersistsParentBeforeLesson()
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
var unitId = Guid.NewGuid();
|
||||
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]),
|
||||
new FakeAlternativeLessonPaths([]), new FakeTimetableSlots(), NewPeriodSchedule(),
|
||||
new FakeAttachmentStorage(), unitId, Guid.NewGuid(), "10c", "Chemie", [], [], null);
|
||||
vm.Topic = "Test";
|
||||
vm.DateText = "09.11.2026";
|
||||
var called = false;
|
||||
vm.BeforeSave = () =>
|
||||
{
|
||||
Assert.Empty(lessons.GetByUnit(unitId));
|
||||
called = true;
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.True(called);
|
||||
Assert.Equal(unitId, Assert.Single(lessons.GetByUnit(unitId)).UnitId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_InvalidInputDoesNotPersistParent()
|
||||
{
|
||||
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
|
||||
var called = false;
|
||||
vm.BeforeSave = () => called = true;
|
||||
vm.DateText = "invalid";
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.False(called);
|
||||
Assert.Null(vm.Result);
|
||||
}
|
||||
|
||||
private static PeriodScheduleService NewPeriodSchedule()
|
||||
{
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed partial class SettingsViewModelTests
|
||||
{
|
||||
private const string UsageJson = """
|
||||
{"apiVersion":"1","schema":"lehrerbackend.usage.v1",
|
||||
"period":{"days":28,"from":"2026-09-24T12:00:00","to":"2026-10-22T12:00:00"},
|
||||
"totals":{"calls":3,"chargedUsd":"0.300000","chargedEur":null},
|
||||
"byAction":[
|
||||
{"actionId":"plan","label":"Unterricht planen","operation":null,"calls":2,"chargedUsd":"0.200000","chargedEur":"0.180000"},
|
||||
{"actionId":"worksheet_rewrite_openai","label":"Text überarbeiten","operation":"rewrite","calls":1,"chargedUsd":"0.100000","chargedEur":null}],
|
||||
"entries":[{"at":"2026-10-22T11:00:00","actionId":"plan","label":"Unterricht planen","operation":null,"status":"settled","chargedUsd":"0.100000","chargedEur":"0.090000"}],
|
||||
"entriesTruncated":true,"features":{"spellcheck":true}}
|
||||
""";
|
||||
|
||||
private static AiPlanningService AiWithHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler) =>
|
||||
new(new HttpClient(new AccountHandler(handler)) { BaseAddress = new Uri("https://example.invalid/backend/") },
|
||||
new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new FakeCompetencyDomains(),
|
||||
new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]), TestSupport.BuildAppLogger());
|
||||
|
||||
private static HttpResponseMessage JsonResponse(string json, HttpStatusCode status = HttpStatusCode.OK) =>
|
||||
new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
|
||||
|
||||
private sealed class AccountHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => handler(request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiAccount_AuthUndVerbrauchWerdenGeladenUndWaehrungenBleibenGetrennt()
|
||||
{
|
||||
var requests = new List<string>();
|
||||
var ai = AiWithHandler(request =>
|
||||
{
|
||||
Assert.Equal("Bearer", request.Headers.Authorization?.Scheme);
|
||||
Assert.Equal("test-token", request.Headers.Authorization?.Parameter);
|
||||
requests.Add(request.RequestUri!.PathAndQuery);
|
||||
return Task.FromResult(JsonResponse(request.RequestUri.AbsolutePath.EndsWith("status.php")
|
||||
? """{"balanceUsd":3.5,"balanceEur":3.1}""" : UsageJson));
|
||||
});
|
||||
var settings = TestSupport.BuildAiSettingsService();
|
||||
var vm = BuildViewModel(aiSettings: settings, aiPlanning: ai);
|
||||
settings.SetCredentialsAndToken("test", "test-token");
|
||||
|
||||
await vm.RefreshAiAccountCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(["/backend/status.php", "/backend/api/v1/usage.php?days=28"], requests);
|
||||
Assert.Contains("3,50 USD", vm.AiBalanceDisplay);
|
||||
Assert.Contains("3,10 €", vm.AiBalanceDisplay);
|
||||
Assert.True(vm.AiHasUsage);
|
||||
Assert.Equal(2, vm.AiUsageGroups.Count);
|
||||
Assert.Equal(3, vm.AiUsageGroups.Sum(g => g.Calls));
|
||||
Assert.Contains("0,30 USD", vm.AiUsageSummary);
|
||||
Assert.DoesNotContain("€", vm.AiUsageSummary); // Euro-Gegenwert nicht für alle Aufrufe vorhanden
|
||||
Assert.Equal("Unterricht planen", Assert.Single(vm.AiUsageEntries).Label);
|
||||
Assert.Contains("neuesten 1", vm.AiUsageStatus);
|
||||
Assert.Contains("2×", vm.AiFeatures.Single(f => f.ActionId == "plan").Usage);
|
||||
Assert.Equal("Kostenlos", vm.AiFeatures.Single(f => f.ActionId == null).Usage);
|
||||
Assert.False(vm.AiIsRefreshing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiAccount_AltesBackend_BehaeltGuthabenUndErklaertFehlendenVerbrauch()
|
||||
{
|
||||
var ai = AiWithHandler(request => Task.FromResult(request.RequestUri!.AbsolutePath.EndsWith("status.php")
|
||||
? JsonResponse("""{"balanceUsd":2}""") : JsonResponse("{}", HttpStatusCode.NotFound)));
|
||||
var settings = TestSupport.BuildAiSettingsService();
|
||||
var vm = BuildViewModel(aiSettings: settings, aiPlanning: ai);
|
||||
settings.SetCredentialsAndToken("test", "token");
|
||||
|
||||
await vm.RefreshAiAccountCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("Guthaben: 2,00 USD", vm.AiBalanceDisplay);
|
||||
Assert.Contains("noch keine Verbrauchsübersicht", vm.AiUsageStatus);
|
||||
Assert.Empty(vm.AiUsageGroups);
|
||||
Assert.False(vm.AiIsRefreshing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiAccount_AbmeldenWaerendLaden_VerhindertRueckkehrDerKontodaten()
|
||||
{
|
||||
var response = new TaskCompletionSource<HttpResponseMessage>();
|
||||
var settings = TestSupport.BuildAiSettingsService();
|
||||
var vm = BuildViewModel(aiSettings: settings, aiPlanning: AiWithHandler(_ => response.Task));
|
||||
settings.SetCredentialsAndToken("test", "token");
|
||||
var refresh = vm.RefreshAiAccountCommand.ExecuteAsync(null);
|
||||
Assert.True(vm.AiIsRefreshing);
|
||||
|
||||
vm.AiLogoutCommand.Execute(null);
|
||||
response.SetResult(JsonResponse("""{"balanceUsd":99}"""));
|
||||
await refresh;
|
||||
|
||||
Assert.False(settings.IsLoggedIn);
|
||||
Assert.False(vm.AiIsLoggedIn);
|
||||
Assert.False(vm.AiIsRefreshing);
|
||||
Assert.Empty(vm.AiBalanceDisplay);
|
||||
Assert.Empty(vm.AiUsageEntries);
|
||||
Assert.Empty(vm.AiUsageGroups);
|
||||
Assert.Contains("Nach der Anmeldung", vm.AiUsageStatus);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{}")]
|
||||
[InlineData("<html>Fehler</html>")]
|
||||
[InlineData("null")]
|
||||
public async Task AiAccount_UngueltigesGuthaben_LiefertVerstaendlichenFehler(string json)
|
||||
{
|
||||
var ai = AiWithHandler(_ => Task.FromResult(JsonResponse(json)));
|
||||
var error = await Assert.ThrowsAsync<AiBackendException>(() => ai.GetAccountBalanceAsync("token"));
|
||||
Assert.Contains("Guthaben", error.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0.300000", "null")]
|
||||
[InlineData("lehrerbackend.usage.v1", "unbekannt")]
|
||||
[InlineData("2026-10-22T11:00:00", "ungueltig")]
|
||||
public void AiUsage_UngueltigeAntwortWirdAbgelehnt(string oldValue, string newValue)
|
||||
{
|
||||
Assert.Throws<AiBackendException>(() => AiUsageReport.Parse(UsageJson.Replace(oldValue, newValue)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiAccount_NetzwerkfehlerUndAbgelaufeneAnmeldungWerdenErklaert()
|
||||
{
|
||||
var unavailable = AiWithHandler(_ => throw new HttpRequestException());
|
||||
Assert.Contains("nicht erreichbar", (await Assert.ThrowsAsync<AiBackendException>(() => unavailable.GetUsageAsync("token"))).Message);
|
||||
var expired = AiWithHandler(_ => Task.FromResult(JsonResponse("{}", HttpStatusCode.Unauthorized)));
|
||||
Assert.Contains("Anmeldung abgelaufen", (await Assert.ThrowsAsync<AiBackendException>(() => expired.GetUsageAsync("token"))).Message);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class SettingsViewModelTests
|
||||
public sealed partial class SettingsViewModelTests
|
||||
{
|
||||
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
|
||||
FakeSupervisionDuties? supervisionDuties = null,
|
||||
@@ -19,7 +19,7 @@ public sealed class SettingsViewModelTests
|
||||
FakeCompetencyCatalogVersions? competencyVersions = null,
|
||||
EventQueue? eventQueue = null, SyncKeyStatus? syncKeyStatus = null,
|
||||
SyncKeyRecoveryService? syncKeyRecovery = null, AppearanceSettingsService? appearance = null,
|
||||
TrashViewModel? trashTab = null)
|
||||
TrashViewModel? trashTab = null, AiSettingsService? aiSettings = null, AiPlanningService? aiPlanning = null)
|
||||
{
|
||||
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
|
||||
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
@@ -37,7 +37,7 @@ public sealed class SettingsViewModelTests
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||
new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), aiSettings ?? TestSupport.BuildAiSettingsService(), aiPlanning ?? TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildMcpSettingsService(),
|
||||
TestSupport.BuildMcpClientRegistrationService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class StudentReportTests
|
||||
{
|
||||
private readonly Student _student = new() { FirstName = "Erika", LastName = "Beispiel" };
|
||||
private readonly LearningGroup _group = new() { Name = "9 Ch G", SchoolYear = "2026/27" };
|
||||
private readonly DateOnly _date = new(2026, 10, 1);
|
||||
private readonly FakeDocumentation _docs = new();
|
||||
private readonly FakeEntries _entries = new();
|
||||
private readonly FakeAspects _aspects = new();
|
||||
private readonly List<ParticipationSession> _sessions = [];
|
||||
|
||||
private StudentReportService Service() => new(new FakeSessions(_sessions), _entries, _aspects,
|
||||
new FakeExams([]), new FakeResults(), new FakeGrades(), new FakeMemberships([]),
|
||||
new FakeCompetencyDomains(), _docs, new GradingService(), new CompetencyAnalysisService());
|
||||
|
||||
private Dictionary<string, PlaceholderValue> Build(StudentReportOptions? options = null) => Service().Build(
|
||||
_student, _group, StudentReportTemplate.Create(), options ?? new(_date, _date.AddMonths(1), null, IncludeInterim: false));
|
||||
|
||||
[Fact]
|
||||
public void Dokumentation_IstAufSchuelerGruppeZeitraumUndFreigabeBegrenzt()
|
||||
{
|
||||
Documentation Doc(string title) => new() { StudentId = _student.Id, GroupId = _group.Id, Date = _date, Title = title };
|
||||
_docs.Add(Doc("sichtbar"));
|
||||
var confidential = Doc("vertraulich"); confidential.IsConfidential = true; _docs.Add(confidential);
|
||||
var draft = Doc("Entwurf"); draft.IsDraft = true; _docs.Add(draft);
|
||||
var deleted = Doc("gelöscht"); deleted.IsDeleted = true; _docs.Add(deleted);
|
||||
var otherGroup = Doc("fremdeGruppe"); otherGroup.GroupId = Guid.NewGuid(); _docs.Add(otherGroup);
|
||||
var otherStudent = Doc("fremderSchüler"); otherStudent.StudentId = Guid.NewGuid(); _docs.Add(otherStudent);
|
||||
var outside = Doc("außerhalb"); outside.Date = _date.AddDays(-1); _docs.Add(outside);
|
||||
|
||||
var text = Text((DrawingValue)Build()[StudentReportTemplate.Documentation]);
|
||||
Assert.Contains("sichtbar", text);
|
||||
foreach (var hidden in new[] { "vertraulich", "Entwurf", "gelöscht", "fremdeGruppe", "fremderSchüler", "außerhalb" })
|
||||
Assert.DoesNotContain(hidden, text);
|
||||
var included = Build(new(_date, _date, null, IncludeInterim: false, IncludeConfidential: true));
|
||||
Assert.Contains("[Vertraulich]", Text((DrawingValue)included[StudentReportTemplate.Documentation]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zwischenstand_MussZumSchuelerZurGruppeUndZumZeitraumPassen()
|
||||
{
|
||||
var interim = new InterimGrade { StudentId = _student.Id, GroupId = _group.Id, Date = _date,
|
||||
Occasion = "Sprechtag", OralValue = "2", WrittenValue = "3", OverallValue = "2-3" };
|
||||
var values = Build(new(_date, _date, interim));
|
||||
Assert.Contains("2-3", Text((DrawingValue)values[ReportTextDrawing.InterimGrades]));
|
||||
interim.StudentId = Guid.NewGuid();
|
||||
Assert.Throws<InvalidDataException>(() => Build(new(_date, _date, interim)));
|
||||
interim.StudentId = _student.Id; interim.GroupId = Guid.NewGuid();
|
||||
Assert.Throws<InvalidDataException>(() => Build(new(_date, _date, interim)));
|
||||
interim.GroupId = _group.Id; interim.Date = _date.AddDays(1);
|
||||
Assert.Throws<InvalidDataException>(() => Build(new(_date, _date, interim)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgewaehlteBausteineBleibenLeer()
|
||||
{
|
||||
var values = Build(new(_date, _date, null, false, false, false, false, false));
|
||||
Assert.All(StudentReportTemplate.Drawings, name => Assert.Empty(((DrawingValue)values[name]).Commands));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hausaufgaben_AltdatenUndFremdeSitzungen()
|
||||
{
|
||||
var session = new ParticipationSession { GroupId = _group.Id, Date = _date };
|
||||
_sessions.Add(session);
|
||||
_entries.Add(new() { StudentId = _student.Id, SessionId = session.Id, HomeworkMissing = true });
|
||||
_entries.Add(new() { StudentId = _student.Id, SessionId = Guid.NewGuid(), Homework = HomeworkStatus.Completed });
|
||||
var text = Text((DrawingValue)Build()[StudentReportTemplate.Homework]);
|
||||
Assert.Contains("Nicht gemacht", text);
|
||||
Assert.DoesNotContain("Hausaufgabe gemacht", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standardbericht_RenderbarMitGraphUndMehrseitigerDokumentation()
|
||||
{
|
||||
_aspects.Save(new() { Key = "quality", Label = "Qualität", Weight = 1 });
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
var session = new ParticipationSession { GroupId = _group.Id, Date = _date.AddDays(i) };
|
||||
_sessions.Add(session);
|
||||
_entries.Add(new() { StudentId = _student.Id, SessionId = session.Id,
|
||||
Ratings = [new() { Key = "quality", Value = i - 2 }], Homework = HomeworkStatus.Completed });
|
||||
}
|
||||
for (var i = 0; i < 55; i++) _docs.Add(new() { StudentId = _student.Id, GroupId = _group.Id,
|
||||
Date = _date, Title = $"Eintrag {i:D2}", Content = "Lange Beobachtung mit einem ausführlichen Text über die regelmäßige Mitarbeit im Unterricht und die Lernfortschritte." });
|
||||
var pdf = new QuestTemplateRenderer().RenderToPdf(StudentReportTemplate.Create(), new ReportProvider(Build()));
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
|
||||
Assert.True(document.NumberOfPages > 1);
|
||||
Assert.All(document.GetPages(), page => Assert.Contains(_student.FullName.Replace(" ", ""), page.Text.Replace(" ", "")));
|
||||
var text = string.Concat(document.GetPages().Select(p => p.Text)).Replace(" ", "");
|
||||
Assert.Contains(_student.FullName.Replace(" ", ""), text);
|
||||
Assert.Contains("Lerngruppe:9ChG", text);
|
||||
Assert.Contains("GewichteteMitarbeit", text);
|
||||
Assert.Contains("Eintrag54", text);
|
||||
Assert.Contains("Lernfortschritte.", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dialog_UngueltigeDatumsangabenErzeugenKeineDatei()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var vm = new StudentReportDialogViewModel(_student,
|
||||
new FakeMemberships([new() { StudentId = _student.Id, GroupId = _group.Id }]),
|
||||
new FakeGroups([_group]), new FakeInterimGrades(), Service(), new QuestTemplateRenderer(),
|
||||
new SchoolYearService(), [new TemplateStore(directory)]);
|
||||
vm.From = "31.02.2026";
|
||||
var output = Path.Combine(directory, "bericht.pdf");
|
||||
Assert.False(await vm.GenerateAsync(output));
|
||||
Assert.False(File.Exists(output));
|
||||
Assert.Contains("TT.MM.JJJJ", vm.StatusMessage);
|
||||
}
|
||||
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
private sealed class ReportProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
|
||||
|
||||
private static string Text(DrawingValue drawing) => string.Join(" ", drawing.Commands.OfType<DrawStringEx>().Select(c => c.Text));
|
||||
}
|
||||
@@ -11,6 +11,46 @@ namespace LehrerApp.Desktop.Tests;
|
||||
/// Roster-/WebUntis-Abhängigkeit, deshalb reine In-Memory-Fakes ohne Untis-Cache-Setup.
|
||||
public sealed class StudentVorgaengeViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BestehenderAkteneintrag_KannNachtraeglichVerknuepftUndWiederGeloestWerden()
|
||||
{
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
var doc = new Documentation { StudentId = student.Id, Title = "Bericht" };
|
||||
var documentation = new FakeDocumentation();
|
||||
documentation.Add(doc);
|
||||
var vorgaenge = new FakeVorgangRepository();
|
||||
var vorgang = new Vorgang { Title = "Vorgang", StudentIds = [student.Id] };
|
||||
vorgaenge.Add(vorgang);
|
||||
var vm = BuildVm(vorgaenge, documentation, new FakeStudents([student]));
|
||||
vm.Load(student.Id);
|
||||
var item = Assert.Single(vm.Cases);
|
||||
item.IsRevealed = true;
|
||||
var available = Assert.Single(item.AvailableDocumentation);
|
||||
var notifications = new List<string?>();
|
||||
item.PropertyChanged += (_, e) => notifications.Add(e.PropertyName);
|
||||
|
||||
// Die View bindet schon vor dem Befuellen der Collections an diese Eigenschaften.
|
||||
item.AvailableDocumentation.Clear();
|
||||
Assert.Contains(nameof(VorgangItem.HasAvailableDocumentation), notifications);
|
||||
Assert.False(item.HasAvailableDocumentation);
|
||||
item.AvailableDocumentation.Add(available);
|
||||
Assert.True(item.HasAvailableDocumentation);
|
||||
|
||||
await item.LinkCommand.ExecuteAsync(available);
|
||||
var linked = Assert.Single(vm.Cases);
|
||||
Assert.True(linked.IsRevealed);
|
||||
Assert.True(linked.HasLinkedDocumentation);
|
||||
Assert.False(linked.HasAvailableDocumentation);
|
||||
Assert.Equal(doc.Id, Assert.Single(linked.LinkedDocumentation).Model.Id);
|
||||
Assert.Contains(doc.Id, vorgaenge.GetById(vorgang.Id)!.DocumentationIds);
|
||||
|
||||
await linked.UnlinkCommand.ExecuteAsync(linked.LinkedDocumentation[0]);
|
||||
var unlinked = Assert.Single(vm.Cases);
|
||||
Assert.False(unlinked.HasLinkedDocumentation);
|
||||
Assert.True(unlinked.HasAvailableDocumentation);
|
||||
Assert.Empty(vorgaenge.GetById(vorgang.Id)!.DocumentationIds);
|
||||
Assert.Equal(doc.Id, Assert.Single(documentation.GetByStudent(student.Id)).Id);
|
||||
}
|
||||
private static StudentVorgaengeViewModel BuildVm(FakeVorgangRepository? vorgaenge = null,
|
||||
FakeDocumentation? documentation = null, FakeStudents? students = null) =>
|
||||
new(vorgaenge ?? new FakeVorgangRepository(), documentation ?? new FakeDocumentation(),
|
||||
|
||||
@@ -187,6 +187,45 @@ public sealed class UntisReportCacheServiceTests
|
||||
Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAbsencesAsync_AlterHistorischerBerichtWirdErsetztUndDanachGecacht()
|
||||
{
|
||||
var day = DateOnly.FromDateTime(DateTime.Today).AddDays(-30);
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"Schüler*innen\tKlasse\tDatum\tFehlstd.\tFehlmin.\tFehltage\r\n" +
|
||||
$"Muster Max\t10c\t{day:dd.MM.yy}\t2\t90\t0\r\n");
|
||||
var schoolyears = "{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}";
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"s\"}}"), Json(schoolyears), Json(schoolyears),
|
||||
Json("{\"result\":[{\"id\":874,\"name\":\"10c\"}]}"),
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false,\"reportParams\":\"get=rpt.tmp&name=AbsencePerStudent&format=csv\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
|
||||
Json("{\"result\":{}}"));
|
||||
await using var untis = new WebUntisIntegrationService(new HttpClient(handler), TestSupport.BuildWebUntisSettingsService());
|
||||
await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
|
||||
var absences = new FakeUntisAbsenceCache();
|
||||
absences.InsertRange([new UntisAbsenceCacheEntry
|
||||
{
|
||||
ClassName = "10c", StudentName = "Muster Max", Date = Int(day),
|
||||
AbsentPeriods = 2, CountsAsFullDay = true,
|
||||
}]);
|
||||
var states = new FakeUntisCacheFetchStates();
|
||||
states.Save(new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(day),
|
||||
});
|
||||
var cache = new UntisReportCacheService(untis, absences, new FakeUntisClassRegisterCache(),
|
||||
new FakeUntisStudentRosterCache(), states);
|
||||
|
||||
var first = Assert.Single(await cache.GetAbsencesAsync("10c", day, day));
|
||||
Assert.False(first.CountsAsFullDay);
|
||||
Assert.Equal(2, first.AbsentPeriods);
|
||||
var requestCount = handler.Requests.Count;
|
||||
Assert.False(Assert.Single(await cache.GetAbsencesAsync("10c", day, day)).CountsAsFullDay);
|
||||
Assert.Equal(requestCount, handler.Requests.Count);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
|
||||
@@ -238,6 +238,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<GroupMembershipTools>();
|
||||
services.AddSingleton<LetterTemplateTools>();
|
||||
services.AddSingleton<StudentAttendanceCalendarService>();
|
||||
services.AddSingleton<StudentReportService>();
|
||||
services.AddSingleton<CompetencyTools>();
|
||||
services.AddSingleton<GroupTools>();
|
||||
services.AddSingleton<ParticipationTools>();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public sealed record AiAccountBalance([property: JsonRequired] decimal BalanceUsd, decimal? BalanceEur);
|
||||
@@ -146,25 +146,49 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
return result?.Token ?? throw new AiBackendException("Unerwartete Antwort des KI-Dienstes.");
|
||||
}
|
||||
|
||||
public async Task<decimal> GetBalanceAsync(string token)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "status.php");
|
||||
req.Headers.Authorization = new("Bearer", token);
|
||||
public async Task<decimal> GetBalanceAsync(string token) =>
|
||||
(await GetAccountBalanceAsync(token)).BalanceUsd;
|
||||
|
||||
public async Task<AiAccountBalance> GetAccountBalanceAsync(string token)
|
||||
{
|
||||
using var resp = await GetAccountResponseAsync("status.php", token);
|
||||
try
|
||||
{
|
||||
return await resp.Content.ReadFromJsonAsync<AiAccountBalance>(JsonOptions)
|
||||
?? throw new AiBackendException("Das Guthaben des KI-Dienstes konnte nicht verarbeitet werden.");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new AiBackendException("Das Guthaben des KI-Dienstes konnte nicht verarbeitet werden.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AiUsageReport> GetUsageAsync(string token)
|
||||
{
|
||||
using var resp = await GetAccountResponseAsync("api/v1/usage.php?days=28", token);
|
||||
return AiUsageReport.Parse(await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> GetAccountResponseAsync(string path, string token)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
req.Headers.Authorization = new("Bearer", token);
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await http.SendAsync(req); }
|
||||
catch (HttpRequestException)
|
||||
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
|
||||
{
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte später erneut versuchen.");
|
||||
}
|
||||
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new AiBackendException("Guthaben konnte nicht abgerufen werden.");
|
||||
|
||||
var result = await resp.Content.ReadFromJsonAsync<BalanceResult>(JsonOptions);
|
||||
return result?.BalanceUsd ?? 0m;
|
||||
if (resp.IsSuccessStatusCode) return resp;
|
||||
var status = resp.StatusCode;
|
||||
resp.Dispose();
|
||||
throw new AiBackendException(status switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => "Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.",
|
||||
HttpStatusCode.NotFound when path.StartsWith("api/v1/usage") =>
|
||||
"Dieses Backend stellt noch keine Verbrauchsübersicht bereit.",
|
||||
_ => "Die Kontodaten des KI-Dienstes konnten nicht abgerufen werden."
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Rein (nur Repository-Lesezugriffe, kein Netzwerk) — testbar mit Fakes.</summary>
|
||||
@@ -695,7 +719,6 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
}
|
||||
|
||||
private class LoginResult { public string Token { get; set; } = ""; }
|
||||
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
||||
private class BackendErrorResult
|
||||
{
|
||||
public string? Error { get; set; }
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Verbrauch einer Aktion im Zeitraum, z. B. „Text korrigieren“ oder eine Planung der LehrerApp (alle Apps am selben Backend).</summary>
|
||||
/// <param name="Operation">Funktion der Arbeitsblatt-API; null bei Aktionen anderer Apps.</param>
|
||||
/// <param name="ChargedEur">Nur, wenn für jeden Aufruf der Euro-Gegenwert dokumentiert ist; sonst null (der Dienst schätzt nichts).</param>
|
||||
public sealed record AiUsageGroup(string ActionId, string Label, string? Operation, int Calls, decimal ChargedUsd, decimal? ChargedEur);
|
||||
|
||||
/// <summary>Ein berechneter Aufruf. <see cref="At"/> ist die Uhrzeit der Datenbank des Dienstes, ohne Zeitzone.</summary>
|
||||
public sealed record AiUsageEntry(DateTime At, string ActionId, string Label, string? Operation, string Status, decimal ChargedUsd, decimal? ChargedEur);
|
||||
|
||||
/// <param name="SpellcheckGranted">Ob die Rechtschreibprüfung über das Backend freigegeben ist; null, wenn der Dienst es nicht meldet.</param>
|
||||
public sealed record AiUsageReport(int Days, DateTime From, DateTime To, int Calls, decimal ChargedUsd, decimal? ChargedEur,
|
||||
IReadOnlyList<AiUsageGroup> ByAction, IReadOnlyList<AiUsageEntry> Entries, bool EntriesTruncated, bool? SpellcheckGranted)
|
||||
{
|
||||
/// <summary>
|
||||
/// Liest die Antwort von <c>api/v1/usage.php</c>. Unbekannte zusätzliche Felder werden übergangen (der Dienst darf wachsen), fehlende oder
|
||||
/// unplausible Pflichtfelder sind ein Fehler.
|
||||
/// </summary>
|
||||
public static AiUsageReport Parse(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
|
||||
var root = document.RootElement;
|
||||
Need(root.GetProperty("apiVersion").GetString() == "1" && root.GetProperty("schema").GetString() == "lehrerbackend.usage.v1");
|
||||
var period = root.GetProperty("period");
|
||||
var days = period.GetProperty("days").GetInt32();
|
||||
var totals = root.GetProperty("totals");
|
||||
var groups = new List<AiUsageGroup>();
|
||||
foreach (var item in root.GetProperty("byAction").EnumerateArray())
|
||||
{
|
||||
Need(groups.Count < 256);
|
||||
groups.Add(new(Text(item, "actionId", 64), Text(item, "label", 200), Operation(item), item.GetProperty("calls").GetInt32(),
|
||||
RequiredMoney(item, "chargedUsd"), Money(item, "chargedEur")));
|
||||
}
|
||||
var entries = new List<AiUsageEntry>();
|
||||
foreach (var item in root.GetProperty("entries").EnumerateArray())
|
||||
{
|
||||
Need(entries.Count < 1000);
|
||||
entries.Add(new(Date(item.GetProperty("at").GetString()), Text(item, "actionId", 64), Text(item, "label", 200), Operation(item), Text(item, "status", 32),
|
||||
RequiredMoney(item, "chargedUsd"), Money(item, "chargedEur")));
|
||||
}
|
||||
bool? spellcheck = root.TryGetProperty("features", out var features) && features.TryGetProperty("spellcheck", out var flag) && flag.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? flag.GetBoolean() : null;
|
||||
var calls = totals.GetProperty("calls").GetInt32();
|
||||
Need(days is >= 1 and <= 366 && calls >= 0 && groups.All(g => g.Calls >= 0 && g.ChargedUsd >= 0 && g.ChargedEur is null or >= 0) && entries.All(e => e.ChargedUsd >= 0));
|
||||
return new(days, Date(period.GetProperty("from").GetString()), Date(period.GetProperty("to").GetString()), calls, RequiredMoney(totals, "chargedUsd"), Money(totals, "chargedEur"),
|
||||
groups, entries, root.GetProperty("entriesTruncated").GetBoolean(), spellcheck);
|
||||
}
|
||||
catch (Exception e) when (e is JsonException or InvalidOperationException or KeyNotFoundException or FormatException or ArgumentException or OverflowException)
|
||||
{ throw new AiBackendException("Die Verbrauchsdaten des KI-Dienstes konnten nicht verarbeitet werden."); }
|
||||
}
|
||||
|
||||
private static void Need(bool condition) { if (!condition) throw new FormatException(); }
|
||||
|
||||
private static string Text(JsonElement item, string name, int max)
|
||||
{
|
||||
var value = item.GetProperty(name).GetString();
|
||||
Need(!string.IsNullOrWhiteSpace(value) && value.Length <= max);
|
||||
return value!;
|
||||
}
|
||||
|
||||
private static string? Operation(JsonElement item) =>
|
||||
item.TryGetProperty("operation", out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
|
||||
|
||||
private static decimal RequiredMoney(JsonElement item, string name) =>
|
||||
Money(item, name) ?? throw new FormatException();
|
||||
|
||||
private static decimal? Money(JsonElement item, string name)
|
||||
{
|
||||
if (!item.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null) return null;
|
||||
// Dezimaltext mit höchstens sechs Nachkommastellen wie im ganzen Vertrag, nie eine Fließkommazahl
|
||||
var text = value.GetString();
|
||||
Need(text is not null && System.Text.RegularExpressions.Regex.IsMatch(text, @"^\d{1,9}(\.\d{1,6})?$"));
|
||||
return decimal.Parse(text!, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static DateTime Date(string? text)
|
||||
{
|
||||
Need(DateTime.TryParseExact(text, "yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out _));
|
||||
return DateTime.ParseExact(text!, "yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,13 @@ public static class ExamCoverPlaceholderBuilder
|
||||
/// eingetragen, wenn <paramref name="fillResults"/> gesetzt und das Ergebnis korrigiert ist.</param>
|
||||
/// <param name="studentNiveau">Niveau des Schülers in dieser Lerngruppe, falls die Arbeit
|
||||
/// selbst keinem Niveau zugeordnet ist.</param>
|
||||
/// <param name="title">Titel für das Deckblatt, falls er vom internen Klausurtitel abweichen
|
||||
/// soll; null oder leer nimmt den Klausurtitel.</param>
|
||||
public static Dictionary<string, PlaceholderValue> Build(Exam exam, LearningGroup group, string subjectName,
|
||||
Student student, ExamResult? result, bool fillResults, GradingService grading, LoadedTemplate template,
|
||||
DateOnly today, Niveau? studentNiveau = null, string teacherAbbreviation = "")
|
||||
DateOnly today, Niveau? studentNiveau = null, string teacherAbbreviation = "",
|
||||
string? title = null, Exam? original = null, ExamResult? originalResult = null,
|
||||
InterimGrade? interim = null)
|
||||
{
|
||||
var tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
|
||||
var variant = result?.Variant ?? ExamVariant.A;
|
||||
@@ -34,7 +38,7 @@ public static class ExamCoverPlaceholderBuilder
|
||||
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact: null, group, today, "", "");
|
||||
values["Group.GradeLevel"] = new TextValue(group.GradeLevel > 0 ? group.GradeLevel.ToString() : "");
|
||||
values["Subject.Name"] = new TextValue(subjectName);
|
||||
values["Exam.Title"] = new TextValue(exam.Title);
|
||||
values["Exam.Title"] = new TextValue(string.IsNullOrWhiteSpace(title) ? exam.Title : title.Trim());
|
||||
values["Exam.Date"] = new DateValue(exam.Date);
|
||||
values["Exam.Number"] = new TextValue(exam.ExamNumber?.ToString() ?? "");
|
||||
values["Exam.Kind"] = new TextValue(exam.Kind.Label());
|
||||
@@ -59,6 +63,10 @@ public static class ExamCoverPlaceholderBuilder
|
||||
ExamCoverSheetService.Columns(tasks, variant, filled ? result!.Points : null),
|
||||
box.Width, box.MillimeterScale)
|
||||
: new DrawingValue([], 0);
|
||||
values[ReportTextDrawing.RepeatInfo] = DrawingBoxLocator.Find(template, ReportTextDrawing.RepeatInfo) is null
|
||||
? new DrawingValue([], 0) : GradeReportDrawingBuilder.Repeat(exam, original, originalResult, template);
|
||||
values[ReportTextDrawing.InterimGrades] = interim is null
|
||||
? new DrawingValue([], 0) : GradeReportDrawingBuilder.Interim(interim, template);
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ public static class ExamCoverSheetDialogs
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
[App.Services.GetRequiredService<TemplateStore>(), App.Services.GetRequiredService<WorksheetTemplateStore>().Store],
|
||||
App.Services.GetRequiredService<ITemplateBatchRenderer>(),
|
||||
App.Services.GetRequiredService<TeacherSettingsService>().LoadAbbreviation());
|
||||
App.Services.GetRequiredService<TeacherSettingsService>().LoadAbbreviation(),
|
||||
App.Services.GetRequiredService<IExamRepository>(),
|
||||
App.Services.GetRequiredService<IInterimGradeRepository>());
|
||||
await new ExamCoverSheetDialog { DataContext = vm }.ShowDialog(owner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public static class GradeReportDrawingBuilder
|
||||
{
|
||||
public static DrawingValue Interim(InterimGrade grade, LoadedTemplate template) =>
|
||||
Text(template, ReportTextDrawing.InterimGrades, "Vorläufiger Notenzwischenstand",
|
||||
[
|
||||
$"Stichtag: {grade.Date:dd.MM.yyyy} · {grade.Occasion}",
|
||||
$"Mündlich: {Value(grade.OralValue)}",
|
||||
$"Schriftlich: {Value(grade.WrittenValue)}",
|
||||
$"Vorläufige Zeugnisnote: {Value(grade.OverallValue)}",
|
||||
"Dieser Zwischenstand ist keine festgeschriebene Zeugnisnote.",
|
||||
]);
|
||||
|
||||
public static DrawingValue Repeat(Exam exam, Exam? original, ExamResult? originalResult,
|
||||
LoadedTemplate template)
|
||||
{
|
||||
if (exam.RepeatOfExamId is null) return new DrawingValue([], 0);
|
||||
if (original is null || original.Id != exam.RepeatOfExamId || original.GroupId != exam.GroupId)
|
||||
throw new InvalidDataException("Die verknüpfte erste Arbeit ist nicht verfügbar.");
|
||||
var rule = exam.IsSupportWork ? "Die Förderarbeit ersetzt die erste Arbeit. Liegen beide Noten vor, zählt die erste Arbeit."
|
||||
: exam.IsMakeUp ? "Die Nachschreibarbeit ersetzt die versäumte erste Arbeit. Liegen beide Noten vor, zählt die erste Arbeit."
|
||||
: exam.RepeatGradeRule switch
|
||||
{
|
||||
RepeatGradeRule.Repeat => "Für die Bewertung zählt die Note der Wiederholungsarbeit.",
|
||||
RepeatGradeRule.Original => "Für die Bewertung zählt die Note der ersten Arbeit.",
|
||||
_ => "Für die Bewertung zählt die bessere Note aus beiden Arbeiten.",
|
||||
};
|
||||
var grade = originalResult?.Grade;
|
||||
if (string.IsNullOrWhiteSpace(grade) && ExamCoverSheetService.IsCorrected(originalResult))
|
||||
grade = new GradingService().CalculateGrade(originalResult!.TotalPoints,
|
||||
ExamScoringService.Maximum(original.Tasks, originalResult.Variant), original.GradingKey);
|
||||
var firstGrade = originalResult is { Absent: true } ? "abwesend" : Value(grade);
|
||||
return Text(template, ReportTextDrawing.RepeatInfo,
|
||||
exam.IsSupportWork ? "Förderarbeit" : exam.IsMakeUp ? "Nachschreibarbeit" : "Wiederholungsarbeit",
|
||||
[$"Erste Arbeit: {original.Title} vom {original.Date:dd.MM.yyyy}",
|
||||
$"Note der ersten Arbeit: {firstGrade}", rule]);
|
||||
}
|
||||
|
||||
public static DrawingValue Text(LoadedTemplate template, string name, string title, IEnumerable<string> paragraphs)
|
||||
{
|
||||
if (DrawingBoxLocator.Find(template, name) is not { } box) return new DrawingValue([], 0);
|
||||
return ReportTextDrawing.Build(title, paragraphs, box.Width, box.MillimeterScale, box.Height, box.IsFixed);
|
||||
}
|
||||
|
||||
private static string Value(string? value) => string.IsNullOrWhiteSpace(value) ? "nicht erfasst" : value;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class GroupMembershipTools(
|
||||
[Description("Schüler-ID.")] Guid studentId,
|
||||
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||
[Description("Niveau: E, G oder Foerder. Unverändert lassen: weglassen.")] Niveau? niveau = null,
|
||||
[Description("Förderschüler-Markierung (auch ohne Niveaudifferenzierung der Gruppe). Unverändert lassen: weglassen.")] bool? isSupportStudent = null,
|
||||
[Description("Zeitraum: FullYear, H1Only, H2Only oder Custom. Unverändert lassen: weglassen.")] MembershipPeriod? period = null,
|
||||
[Description("Beitrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? joinedAt = null,
|
||||
[Description("Austrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? leftAt = null,
|
||||
@@ -34,6 +35,7 @@ public class GroupMembershipTools(
|
||||
|
||||
var changes = new StringBuilder();
|
||||
if (niveau is not null && niveau != target.Niveau) { changes.AppendLine($"Niveau: {NiveauName(target.Niveau)} → {NiveauName(niveau)}"); target.Niveau = niveau; }
|
||||
if (isSupportStudent is not null && isSupportStudent != target.IsSupportStudent) { changes.AppendLine($"Förderschüler: {(target.IsSupportStudent ? "ja" : "nein")} → {(isSupportStudent.Value ? "ja" : "nein")}"); target.IsSupportStudent = isSupportStudent.Value; }
|
||||
if (period is not null && period != target.Period) { changes.AppendLine($"Zeitraum: {target.Period} → {period}"); target.Period = period.Value; }
|
||||
if (joinedAt is not null && joinedAt != target.JoinedAt) { changes.AppendLine($"Beitritt: {target.JoinedAt:dd.MM.yyyy} → {joinedAt:dd.MM.yyyy}"); target.JoinedAt = joinedAt; }
|
||||
if (leftAt is not null && leftAt != target.LeftAt) { changes.AppendLine($"Austritt: {target.LeftAt:dd.MM.yyyy} → {leftAt:dd.MM.yyyy}"); target.LeftAt = leftAt; }
|
||||
@@ -57,6 +59,7 @@ public class GroupMembershipTools(
|
||||
{
|
||||
Id = m.Id, StudentId = m.StudentId, GroupId = m.GroupId, AddedOn = m.AddedOn,
|
||||
Period = m.Period, JoinedAt = m.JoinedAt, LeftAt = m.LeftAt, Niveau = m.Niveau,
|
||||
IsSupportStudent = m.IsSupportStudent,
|
||||
};
|
||||
|
||||
private static string NiveauName(Niveau? n) => n switch
|
||||
|
||||
@@ -26,7 +26,7 @@ public class LetterTemplateTools(
|
||||
public List<LetterTemplateDto> ListLetterTemplates()
|
||||
{
|
||||
var result = new List<LetterTemplateDto>();
|
||||
foreach (var installed in templates.GetTemplates().Where(x => !x.IsExamCover))
|
||||
foreach (var installed in templates.GetTemplates().Where(x => !x.IsExamCover && !x.IsStudentReport))
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -151,7 +151,7 @@ public static class StudentAttendanceCalendarDrawingBuilder
|
||||
y = gridStartY + 11 * k + 9 * k + maxWeeks * cellHeight + designTrailingGapY * k;
|
||||
|
||||
commands.Add(new DrawStringEx(0, y, 8 * k, contentWidthDesign,
|
||||
"U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch",
|
||||
"U/E/A ganztägig · T teilweise · G gegangen · V verspätet · ! Klassenbuch",
|
||||
DrawingTextAlignment.AlignLeft, 6.5f * finalScale, Color: "#6B7280"));
|
||||
return new DrawingValue(commands, y + 9 * k);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
public sealed record StudentReportOptions(DateOnly From, DateOnly To, InterimGrade? Interim,
|
||||
bool IncludeInterim = true, bool IncludeParticipation = true, bool IncludeCompetencies = true,
|
||||
bool IncludeHomework = true, bool IncludeDocumentation = true, bool IncludeConfidential = false,
|
||||
string Comment = "");
|
||||
|
||||
/// <summary>Stellt die vorhandenen Bewertungen eines Schülers für eine Lerngruppe zusammen.
|
||||
/// Die Berechnungen stammen aus denselben Diensten wie die interaktiven Auswertungen.</summary>
|
||||
public sealed class StudentReportService(
|
||||
IParticipationSessionRepository sessions, IParticipationRepository entries,
|
||||
IParticipationAspectRepository aspects, IExamRepository exams, IExamResultRepository results,
|
||||
IGradeRepository grades, IGroupMembershipRepository memberships,
|
||||
ICompetencyDomainRepository domains, IDocumentationRepository documentation,
|
||||
GradingService grading, CompetencyAnalysisService competencies)
|
||||
{
|
||||
public Dictionary<string, PlaceholderValue> Build(Student student, LearningGroup group,
|
||||
LoadedTemplate template, StudentReportOptions options)
|
||||
{
|
||||
if (options.From > options.To) throw new InvalidDataException("Das Ende des Berichtszeitraums liegt vor dem Beginn.");
|
||||
var values = LetterPlaceholderBuilder.BuildStandardValues(student, null, group, DateOnly.FromDateTime(DateTime.Today), "", "");
|
||||
values["Report.Period"] = new TextValue($"{options.From:dd.MM.yyyy} - {options.To:dd.MM.yyyy}");
|
||||
values["Report.Comment"] = new MultilineValue(options.Comment);
|
||||
foreach (var name in StudentReportTemplate.Drawings) values[name] = new DrawingValue([], 0);
|
||||
if (options.IncludeInterim && Uses(ReportTextDrawing.InterimGrades))
|
||||
{
|
||||
var interim = options.Interim;
|
||||
if (interim is null || interim.StudentId != student.Id || interim.GroupId != group.Id
|
||||
|| interim.Date < options.From || interim.Date > options.To)
|
||||
throw new InvalidDataException("Bitte einen gespeicherten Zwischenstand dieses Schülers im Berichtszeitraum auswählen oder den Baustein abwählen.");
|
||||
values[ReportTextDrawing.InterimGrades] = GradeReportDrawingBuilder.Interim(interim, template);
|
||||
}
|
||||
|
||||
var membership = memberships.GetByStudentAndGroup(student.Id, group.Id);
|
||||
bool Within(DateOnly date) => date >= options.From && date <= options.To
|
||||
&& (membership is null || GroupMembershipService.IsActiveOn(membership, date));
|
||||
var groupSessions = sessions.GetByGroup(group.Id).Where(s => Within(s.Date)).OrderBy(s => s.Date).ToList();
|
||||
var bySession = groupSessions.ToDictionary(s => s.Id);
|
||||
var studentEntries = entries.GetByStudent(student.Id).Where(e => bySession.ContainsKey(e.SessionId)).ToList();
|
||||
var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(group.Id))
|
||||
.GroupBy(a => a.Key).Select(g => g.Last()).ToList();
|
||||
|
||||
if (options.IncludeParticipation && Uses(StudentReportTemplate.Participation))
|
||||
{
|
||||
var timeline = new ParticipationTimelineBuilder(sessions, entries, aspects, exams, results, grades,
|
||||
membership is null ? new Dictionary<Guid, GroupMembership>() : new() { [student.Id] = membership },
|
||||
grading, group.Id, group.GradingSystem);
|
||||
var points = timeline.BuildPoints(student.Id).Where(p => !p.IsExam && Within(p.Date)).ToList();
|
||||
List<ReportChartSeries> series = [new("Gewichtete Mitarbeit", "#6C5CE7",
|
||||
points.Where(p => p.WeightedValue.HasValue).Select(p => new ReportChartPoint(p.Date, p.WeightedValue!.Value)).ToList())];
|
||||
string[] colors = ["#2E86DE", "#E67E22", "#16A085", "#C0392B"];
|
||||
foreach (var (aspect, index) in applicableAspects.Select((a, i) => (a, i)))
|
||||
series.Add(new(aspect.Label, colors[index % colors.Length],
|
||||
points.Where(p => p.AspectValues.ContainsKey(aspect.Key)).Select(p => new ReportChartPoint(p.Date,
|
||||
ParticipationRatingScale.Normalize(aspect.ValueType, p.AspectValues[aspect.Key], aspect.MaxPoints))).ToList()));
|
||||
var box = DrawingBoxLocator.Find(template, StudentReportTemplate.Participation)!.Value;
|
||||
var drawing = ParticipationReportDrawing.Build(series, options.From, options.To, box.Width, box.MillimeterScale);
|
||||
if (drawing.ContentHeight > box.Height)
|
||||
throw new InvalidDataException("Der Mitarbeitsgraph benötigt eine höhere Zeichenbox in der Vorlage.");
|
||||
values[StudentReportTemplate.Participation] = drawing;
|
||||
}
|
||||
if (options.IncludeCompetencies && Uses(StudentReportTemplate.Competencies))
|
||||
{
|
||||
var groupExams = exams.GetByGroup(group.Id).Where(e => Within(e.Date)).ToList();
|
||||
var examIds = groupExams.Select(e => e.Id).ToHashSet();
|
||||
var scores = competencies.Analyze(domains.GetAll(), [], groupExams,
|
||||
results.GetByStudent(student.Id).Where(r => examIds.Contains(r.ExamId)).ToList(),
|
||||
groupSessions, studentEntries, applicableAspects);
|
||||
var lines = scores.Where(s => s.StudentScores.ContainsKey(student.Id)).Select(s =>
|
||||
{
|
||||
var score = s.StudentScores[student.Id];
|
||||
return $"{s.Code} {s.Description}: {score.Percent.ToString("0", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))} % ({score.EvidenceCount} Bewertungen)";
|
||||
}).ToList();
|
||||
Add(StudentReportTemplate.Competencies, "Kompetenzen", lines, "Keine Kompetenzbewertungen im gewählten Zeitraum.");
|
||||
}
|
||||
if (options.IncludeHomework && Uses(StudentReportTemplate.Homework))
|
||||
Add(StudentReportTemplate.Homework, "Hausaufgaben", studentEntries.Where(e => HomeworkDisplay.Effective(e) is not null)
|
||||
.OrderBy(e => bySession[e.SessionId].Date).Select(e =>
|
||||
$"{bySession[e.SessionId].Date:dd.MM.yyyy}: {HomeworkDisplay.Label(HomeworkDisplay.Effective(e))}").ToList(),
|
||||
"Keine Hausaufgabenkontrollen im gewählten Zeitraum.");
|
||||
if (options.IncludeDocumentation && Uses(StudentReportTemplate.Documentation))
|
||||
{
|
||||
var lines = documentation.GetByStudent(student.Id)
|
||||
.Where(d => !d.IsDeleted && !d.IsDraft && (options.IncludeConfidential || !d.IsConfidential)
|
||||
&& d.GroupId == group.Id && Within(d.Date) && d.Concerns(student.Id))
|
||||
.OrderBy(d => d.Date).Select(d =>
|
||||
$"{d.Date:dd.MM.yyyy} · {DocumentationTypeDisplay.Label(d.Type)}: {d.Title}"
|
||||
+ (d.IsConfidential ? " [Vertraulich]" : "") + "\n" + d.Content).ToList();
|
||||
lines.AddRange(studentEntries.Where(e => !string.IsNullOrWhiteSpace(e.Note)).OrderBy(e => bySession[e.SessionId].Date)
|
||||
.Select(e => $"{bySession[e.SessionId].Date:dd.MM.yyyy} · Unterrichtsnotiz: {e.Note}"));
|
||||
Add(StudentReportTemplate.Documentation, "Klassenbucheinträge und Unterrichtsnotizen", lines,
|
||||
"Keine freigegebenen Einträge mit Bezug zu dieser Lerngruppe im gewählten Zeitraum.");
|
||||
}
|
||||
return values;
|
||||
|
||||
bool Uses(string name) => DrawingBoxLocator.Find(template, name) is not null;
|
||||
void Add(string name, string title, List<string> lines, string empty) =>
|
||||
values[name] = GradeReportDrawingBuilder.Text(template, name, title, lines.Count > 0 ? lines : [empty]);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public sealed class UntisReportCacheService(
|
||||
{
|
||||
private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1);
|
||||
private const int HotWindowDays = 14;
|
||||
private const int AbsenceReportVersion = 2;
|
||||
|
||||
/// Roster hat anders als Fehlzeiten/Klassenbuch keine Historie (nur "aktueller Stand einer
|
||||
/// Klasse") - deshalb ohne heißes/kaltes Fenster, nur "gilt der letzte Abruf noch als frisch".
|
||||
@@ -66,6 +67,10 @@ public sealed class UntisReportCacheService(
|
||||
DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
|
||||
{
|
||||
var state = fetchState.Get(className, UntisCacheKind.Absences);
|
||||
// Ältere Berichte haben Tage mit einzelnen Fehlstunden als Fehltage geliefert.
|
||||
// Auch die dauerhaft gecachte Historie muss mit der Trennung neu geladen werden.
|
||||
if (state is not null && state.AbsenceReportVersion != AbsenceReportVersion)
|
||||
state = new UntisCacheFetchState { Id = state.Id, ClassName = className, Kind = UntisCacheKind.Absences };
|
||||
var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow);
|
||||
|
||||
if (plan.RefreshHotWindow)
|
||||
@@ -76,7 +81,7 @@ public sealed class UntisReportCacheService(
|
||||
if (plan.FetchColdRange)
|
||||
{
|
||||
var older = await untis.GetClassAbsencesAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token);
|
||||
absenceCache.InsertRange(older.Select(ToEntry));
|
||||
absenceCache.ReplaceRange(className, Int(plan.ColdRangeStart), Int(plan.ColdRangeEnd), older.Select(ToEntry));
|
||||
}
|
||||
if (plan.RefreshHotWindow || plan.FetchColdRange)
|
||||
fetchState.Save(NextState(state, className, UntisCacheKind.Absences, plan));
|
||||
@@ -137,6 +142,7 @@ public sealed class UntisReportCacheService(
|
||||
Id = previous?.Id ?? Guid.NewGuid(),
|
||||
ClassName = className,
|
||||
Kind = kind,
|
||||
AbsenceReportVersion = kind == UntisCacheKind.Absences ? AbsenceReportVersion : 0,
|
||||
HotWindowFetchedAt = plan.RefreshHotWindow ? DateTime.UtcNow : previous?.HotWindowFetchedAt,
|
||||
ColdCoverageStartDate = plan.ResultingColdCoverageStartDate,
|
||||
};
|
||||
|
||||
@@ -66,6 +66,17 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
|
||||
IReadOnlyList<int> PeriodNumbers, IReadOnlyList<string> Statuses, IReadOnlyList<string> AbsenceReasons,
|
||||
string? Note, string? ExcuseNote, bool CountsAsFullDay)
|
||||
{
|
||||
public bool CountsAsFullDay { get; init; } = CountsAsFullDay && !HasEarlyDepartureReason(AbsenceReasons);
|
||||
public bool IsEarlyDeparture => HasEarlyDepartureReason(AbsenceReasons);
|
||||
|
||||
private static bool HasEarlyDepartureReason(IEnumerable<string> reasons) => reasons.Any(reason =>
|
||||
reason.Contains("vorzeitig", StringComparison.OrdinalIgnoreCase) ||
|
||||
((reason.Contains("früh", StringComparison.OrdinalIgnoreCase) ||
|
||||
reason.Contains("frueh", StringComparison.OrdinalIgnoreCase)) &&
|
||||
(reason.Contains("geh", StringComparison.OrdinalIgnoreCase) ||
|
||||
reason.Contains("gegang", StringComparison.OrdinalIgnoreCase) ||
|
||||
reason.Contains("verlass", StringComparison.OrdinalIgnoreCase))));
|
||||
|
||||
/// Nur für die Gewichtung von stundenweisen Fehlzeiten im Auffälligkeitsscore. Zeugnis- und
|
||||
/// Anzeigezahlen verwenden weiterhin die echten WebUntis-Felder CountsAsFullDay bzw.
|
||||
/// TotalAbsentPeriods und rechnen keine Stunden künstlich in Fehltage um.
|
||||
@@ -84,8 +95,8 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
|
||||
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
|
||||
public string StatusLabel => string.Join(", ", Statuses);
|
||||
public string ReasonLabel => string.Join("; ", AbsenceReasons);
|
||||
public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r =>
|
||||
r.Contains("verspät", StringComparison.OrdinalIgnoreCase));
|
||||
public bool IsLate => !IsEarlyDeparture && (TotalAbsentPeriods == 0 || AbsenceReasons.Any(r =>
|
||||
r.Contains("verspät", StringComparison.OrdinalIgnoreCase)));
|
||||
public bool IsUnexcused => Statuses.Any(s =>
|
||||
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase));
|
||||
public bool IsPartialAbsence => !CountsAsFullDay && (!IsLate || IsUnexcused);
|
||||
@@ -93,7 +104,9 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
|
||||
Math.Min(1d, (TotalAbsentPeriods > 0
|
||||
? TotalAbsentPeriods
|
||||
: TotalAbsentMinutes / 45d) / TypicalPeriodsPerSchoolDayForScoring);
|
||||
public string KindLabel => IsLate ? $"{TotalAbsentMinutes} Min. verspätet" :
|
||||
public string KindLabel => IsEarlyDeparture
|
||||
? $"Früher gegangen · {(TotalAbsentPeriods > 0 ? $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}" : $"{TotalAbsentMinutes} Fehlminuten")}" :
|
||||
IsLate ? $"{TotalAbsentMinutes} Min. verspätet" :
|
||||
CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}";
|
||||
public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" :
|
||||
Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel;
|
||||
@@ -410,6 +423,13 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
: absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
|
||||
? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused)
|
||||
: ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent);
|
||||
// Umfang und Entschuldigungsstatus sind unabhängig: Rot kann auch zwei
|
||||
// unentschuldigte Fehlstunden bedeuten, nicht zwingend einen ganzen Fehltag.
|
||||
if (absence.IsPartialAbsence && !absence.IsLate)
|
||||
code = absence.IsEarlyDeparture ? "G" : "T";
|
||||
else if (absence.IsLate)
|
||||
code = "V";
|
||||
label = $"{absence.KindLabel} · {absence.FriendlyStatusLabel}";
|
||||
events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind));
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,7 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
{
|
||||
if (TodayAbsence is null)
|
||||
return HasRecentClassRegisterEntry ? "Neuer Klassenbucheintrag" : "Keine gemeldete Fehlzeit";
|
||||
var core = IsLate
|
||||
? $"{TodayAbsence.TotalAbsentMinutes} Min. verspätet"
|
||||
: TodayAbsence.CountsAsFullDay
|
||||
? "Ganzer Fehltag"
|
||||
: $"{TodayAbsence.TotalAbsentPeriods} Fehlstunde{(TodayAbsence.TotalAbsentPeriods == 1 ? "" : "n")}";
|
||||
var core = TodayAbsence.KindLabel;
|
||||
return IsUnexcused ? $"{core} · Unentschuldigt" : core;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@ public partial class VorgangItem : ObservableObject
|
||||
_onUnlink = onUnlink;
|
||||
_onRemoveClassRegisterEntry = onRemoveClassRegisterEntry;
|
||||
_onEditDocumentation = onEditDocumentation;
|
||||
LinkedDocumentation.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLinkedDocumentation));
|
||||
AvailableDocumentation.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasAvailableDocumentation));
|
||||
ClassRegisterRows.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasClassRegisterRows));
|
||||
}
|
||||
|
||||
[RelayCommand] private void Reveal() => IsRevealed = !IsRevealed;
|
||||
|
||||
@@ -21,6 +21,17 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
|
||||
private readonly GradingService _grading;
|
||||
private readonly TemplateStore _loader;
|
||||
private readonly ITemplateBatchRenderer _renderer;
|
||||
private readonly Exam? _original;
|
||||
private readonly Dictionary<Guid, ExamResult> _originalResults;
|
||||
[ObservableProperty] private ReportInterimRunChoice? _selectedInterimRun;
|
||||
[ObservableProperty] private bool _usesInterimGrades;
|
||||
public ObservableCollection<ReportInterimRunChoice> InterimRuns { get; } = [];
|
||||
|
||||
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
|
||||
{
|
||||
UsesInterimGrades = value is not null &&
|
||||
DrawingBoxLocator.Find(_loader.Load(value.Model), ReportTextDrawing.InterimGrades) is not null;
|
||||
}
|
||||
|
||||
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
|
||||
[ObservableProperty] private bool _fillResults;
|
||||
@@ -28,21 +39,41 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
|
||||
/// <summary>Vorbelegt aus den Einstellungen, hier nur für diesen Druck änderbar (z. B. zwei
|
||||
/// Kürzel bei einem gemeinsam unterrichteten Kurs) — die Einstellung bleibt unberührt.</summary>
|
||||
[ObservableProperty] private string _teacherAbbreviation;
|
||||
/// <summary>Vorbelegt mit dem internen Klausurtitel, hier nur für diesen Druck änderbar — der
|
||||
/// Titel der Klausur bleibt unberührt. Leer gelassen gilt wieder der interne Titel.</summary>
|
||||
[ObservableProperty] private string _coverTitle;
|
||||
[ObservableProperty, NotifyPropertyChangedFor(nameof(CanGenerate))] private bool _isGenerating;
|
||||
[ObservableProperty] private int _progressValue;
|
||||
[ObservableProperty] private int _progressMaximum = 1;
|
||||
/// <summary>Alle Deckblätter sind gesetzt, das Sammel-PDF entsteht — dieser Schritt meldet
|
||||
/// keinen Zwischenstand, der Balken läuft deshalb unbestimmt.</summary>
|
||||
[ObservableProperty] private bool _isMerging;
|
||||
[ObservableProperty] private string _progressText = "";
|
||||
|
||||
public string ExamTitle => _exam.Title;
|
||||
public string ExamLabel => $"{_exam.Title} · {_group.Name} · {_exam.Date:dd.MM.yyyy}";
|
||||
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
|
||||
public ObservableCollection<ExamCoverStudentChoice> Students { get; } = [];
|
||||
public bool HasNoTemplates => Templates.Count == 0;
|
||||
public bool CanGenerate => !HasNoTemplates && !IsGenerating;
|
||||
public string SuggestedFileName => SanitizeFileName($"Deckblätter_{_group.Name}_{_exam.Title}.pdf");
|
||||
|
||||
public ExamCoverSheetDialogViewModel(Exam exam, LearningGroup group, string subjectName,
|
||||
IStudentRepository students, IGroupMembershipRepository memberships, IExamResultRepository results,
|
||||
GradingService grading, IReadOnlyList<TemplateStore> templateStores, ITemplateBatchRenderer renderer,
|
||||
string teacherAbbreviation = "")
|
||||
string teacherAbbreviation = "", IExamRepository? exams = null, IInterimGradeRepository? interims = null)
|
||||
{
|
||||
_teacherAbbreviation = teacherAbbreviation;
|
||||
_coverTitle = exam.Title;
|
||||
_exam = exam; _group = group; _subjectName = subjectName; _grading = grading; _renderer = renderer;
|
||||
_loader = templateStores[0];
|
||||
_original = exam.RepeatOfExamId is { } originalId ? exams?.GetById(originalId) : null;
|
||||
_originalResults = exam.RepeatOfExamId is { } firstId
|
||||
? results.GetByExam(firstId).ToDictionary(r => r.StudentId) : [];
|
||||
foreach (var run in (interims?.GetByGroup(group.Id) ?? [])
|
||||
.GroupBy(g => (g.Date, g.Occasion)).OrderByDescending(g => g.Key.Date).ThenBy(g => g.Key.Occasion))
|
||||
InterimRuns.Add(new(run.Key.Date, run.Key.Occasion, run.ToList()));
|
||||
// Bewusste Auswahl statt stillschweigend den neuesten Zwischenstand zu drucken.
|
||||
foreach (var template in templateStores.SelectMany(x => x.GetTemplates()).Where(x => x.IsExamCover)
|
||||
.DistinctBy(x => x.Id).OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||
Templates.Add(new(template));
|
||||
@@ -63,22 +94,40 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
|
||||
FillResults = Students.Any(s => s.IsCorrected);
|
||||
}
|
||||
|
||||
/// <summary>Schreibt das Sammel-PDF und meldet den Erfolg.</summary>
|
||||
public bool Generate(string outputPath)
|
||||
/// <summary>Schreibt das Sammel-PDF und meldet den Erfolg. Gesetzt wird im Hintergrund, damit
|
||||
/// der Dialog bei einem Klassensatz bedienbar bleibt und den Fortschritt zeigen kann.</summary>
|
||||
public async Task<bool> GenerateAsync(string outputPath)
|
||||
{
|
||||
StatusMessage = "";
|
||||
if (SelectedTemplate is null) return false;
|
||||
if (SelectedTemplate is null || IsGenerating) return false;
|
||||
var included = Students.Where(s => s.IsIncluded).ToList();
|
||||
if (included.Count == 0) { StatusMessage = "Kein Schüler ausgewählt."; return false; }
|
||||
try
|
||||
{
|
||||
var loaded = _loader.Load(SelectedTemplate.Model);
|
||||
if (UsesInterimGrades && SelectedInterimRun is null)
|
||||
{ StatusMessage = "Bitte einen gespeicherten Notenzwischenstand auswählen."; return false; }
|
||||
if (UsesInterimGrades)
|
||||
{
|
||||
var missing = included.Where(s => !SelectedInterimRun!.Grades.Any(g => g.StudentId == s.Model.Id)).ToList();
|
||||
if (missing.Count > 0)
|
||||
{ StatusMessage = "Im gewählten Zwischenstand fehlen: " + string.Join(", ", missing.Select(s => s.FullName)) + ". Bitte Schüler abwählen oder einen anderen Durchgang wählen."; return false; }
|
||||
}
|
||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||
var data = included.Select(s => (ITemplateDataProvider)new LetterDataProvider(
|
||||
ExamCoverPlaceholderBuilder.Build(_exam, _group, _subjectName, s.Model, s.Result, FillResults,
|
||||
_grading, loaded, today, s.Niveau, TeacherAbbreviation))).ToList();
|
||||
var result = _renderer.RenderBatchToPdf(loaded, data);
|
||||
File.WriteAllBytes(outputPath, result.Pdf);
|
||||
_grading, loaded, today, s.Niveau, TeacherAbbreviation, CoverTitle,
|
||||
_original, _originalResults.GetValueOrDefault(s.Model.Id),
|
||||
UsesInterimGrades ? SelectedInterimRun!.Grades.Single(g => g.StudentId == s.Model.Id) : null))).ToList();
|
||||
ReportProgress(0, included.Count);
|
||||
IsGenerating = true;
|
||||
var progress = new Progress<int>(done => ReportProgress(done, included.Count));
|
||||
var result = await Task.Run(() =>
|
||||
{
|
||||
var rendered = _renderer.RenderBatchToPdf(loaded, data, progress);
|
||||
File.WriteAllBytes(outputPath, rendered.Pdf);
|
||||
return rendered;
|
||||
});
|
||||
|
||||
var message = $"{included.Count} Deckblätter erzeugt.";
|
||||
var blank = FillResults ? included.Count(s => !s.IsCorrected) : 0;
|
||||
@@ -89,8 +138,19 @@ public partial class ExamCoverSheetDialogViewModel : ObservableObject
|
||||
StatusMessage = message;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException or QuestPDF.Drawing.Exceptions.DocumentLayoutException)
|
||||
{ StatusMessage = $"Deckblätter konnten nicht erzeugt werden: {ex.Message}"; return false; }
|
||||
finally { IsGenerating = false; }
|
||||
}
|
||||
|
||||
private void ReportProgress(int done, int total)
|
||||
{
|
||||
ProgressMaximum = total;
|
||||
ProgressValue = done;
|
||||
IsMerging = done >= total && total > 1;
|
||||
ProgressText = IsMerging
|
||||
? "Sammel-PDF wird zusammengefügt …"
|
||||
: $"{done} von {total} Deckblättern gesetzt …";
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
@@ -114,3 +174,8 @@ public partial class ExamCoverStudentChoice : ObservableObject
|
||||
_isIncluded = result is not { Absent: true };
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ReportInterimRunChoice(DateOnly Date, string Occasion, IReadOnlyList<InterimGrade> Grades)
|
||||
{
|
||||
public string Label => $"{Date:dd.MM.yyyy} · {Occasion}";
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
public ObservableCollection<RepeatComparisonItem> RepeatComparison { get; } = [];
|
||||
public bool HasRepeatComparison => RepeatComparison.Count > 0;
|
||||
public string RepeatComparisonTitle { get; private set; } = "";
|
||||
/// "Wiederholung" bzw. "Nachschreibarbeit" — Beschriftung der Folgearbeit im Vergleich.
|
||||
/// "Wiederholung", "Nachschreibarbeit" bzw. "Förderarbeit" — Beschriftung der Folgearbeit im Vergleich.
|
||||
public string FollowUpLabel { get; private set; } = "Wiederholung";
|
||||
public string FollowUpComparisonHeading => $"Vergleich mit der {FollowUpLabel}";
|
||||
public ObservableCollection<GradingKeyEntryEditItem> GradingKeyEntries { get; } = [];
|
||||
@@ -72,8 +72,8 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
: _exams.GetByGroup(_exam.GroupId).Where(e => e.RepeatOfExamId == _exam.Id)
|
||||
.OrderBy(e => e.Date).FirstOrDefault();
|
||||
if (repeat is null) return;
|
||||
FollowUpLabel = repeat.IsMakeUp ? "Nachschreibarbeit" : "Wiederholung";
|
||||
var rule = repeat.IsMakeUp ? "ersetzt fehlende Note" : repeat.RepeatGradeRule switch
|
||||
FollowUpLabel = repeat.IsSupportWork ? "Förderarbeit" : repeat.IsMakeUp ? "Nachschreibarbeit" : "Wiederholung";
|
||||
var rule = repeat.ReplacesMissingGrade() ? "ersetzt fehlende Note" : repeat.RepeatGradeRule switch
|
||||
{
|
||||
RepeatGradeRule.Repeat => "Wiederholung zählt",
|
||||
RepeatGradeRule.Original => "Erste Arbeit zählt",
|
||||
|
||||
@@ -65,11 +65,21 @@ public partial class ExamGradingDialogViewModel : ObservableObject
|
||||
var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey,
|
||||
ExamScoringService.Maximum(Tasks, ExamVariant.A), _grading);
|
||||
row.OnChanged = SaveRow;
|
||||
row.HasStoredResult = result is not null;
|
||||
Rows.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
|
||||
/// Eine Zeile, die nur angefahren wurde (Fokus rein, Fokus raus), ist kein Ergebnis: ohne
|
||||
/// Punkte, Abwesenheit oder Kommentar und ohne bereits gespeichertes Ergebnis wird nichts
|
||||
/// angelegt — sonst stünde der Schüler später mit 0 Punkten und Note 6 in der Liste.
|
||||
private void SaveRow(ExamResultRow row)
|
||||
{
|
||||
if (!row.HasStoredResult && !row.HasPoints && !row.Absent && string.IsNullOrWhiteSpace(row.Comment))
|
||||
return;
|
||||
_results.Save(row.ToModel(_exam.Id));
|
||||
row.HasStoredResult = true;
|
||||
}
|
||||
|
||||
partial void OnEntryOrderNameChanged(string value) =>
|
||||
_settings?.Save(new ExamGradingSettings { EntryOrder = EntryOrder });
|
||||
@@ -160,6 +170,12 @@ public partial class ExamResultRow : ObservableObject
|
||||
|
||||
public Action<ExamResultRow>? OnChanged { get; set; }
|
||||
|
||||
/// Mindestens eine Aufgabe hat einen eingetragenen Wert (auch 0). Ohne Werte gibt es weder
|
||||
/// Summe noch Note.
|
||||
public bool HasPoints => Cells.Any(c => c.Value is not null);
|
||||
/// Für diesen Schüler liegt in der Datenbank bereits ein Ergebnis dieser Arbeit.
|
||||
public bool HasStoredResult { get; set; }
|
||||
|
||||
public ExamResultRow(Guid studentId, string name, List<ExamTask> tasks, ExamResult? existing,
|
||||
List<GradingKeyEntry> gradingKey, double examMaxPoints, GradingService grading)
|
||||
{
|
||||
@@ -202,7 +218,7 @@ public partial class ExamResultRow : ObservableObject
|
||||
{
|
||||
TotalPoints = Cells.Sum(c => c.IsApplicable && !c.IsInvalid ? (c.Value ?? 0) : 0);
|
||||
OnPropertyChanged(nameof(TotalPointsDisplay));
|
||||
GradeDisplay = Absent ? "abwesend" : _grading.CalculateGrade(TotalPoints,
|
||||
GradeDisplay = Absent ? "abwesend" : !HasPoints ? "" : _grading.CalculateGrade(TotalPoints,
|
||||
ExamScoringService.Maximum(_tasks, Variant), _gradingKey);
|
||||
}
|
||||
|
||||
@@ -211,9 +227,9 @@ public partial class ExamResultRow : ObservableObject
|
||||
Id = _resultId,
|
||||
ExamId = examId,
|
||||
StudentId = StudentId,
|
||||
Points = Cells.Select(c => c.Value ?? 0).ToList(),
|
||||
Points = HasPoints ? Cells.Select(c => c.Value ?? 0).ToList() : [],
|
||||
TotalPoints = TotalPoints,
|
||||
Grade = Absent ? null : GradeDisplay,
|
||||
Grade = Absent || !HasPoints ? null : GradeDisplay,
|
||||
Absent = Absent,
|
||||
Variant = Variant,
|
||||
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
|
||||
@@ -240,11 +256,14 @@ public partial class PointsCell : ObservableObject
|
||||
|
||||
/// Setzt den Wert; bei Punkten außerhalb 0..Maximalpunkte wird nur rot markiert,
|
||||
/// aber nicht gespeichert (1.4.5) — OnChanged (und damit Autosave) wird dann nicht ausgelöst.
|
||||
/// Auch unverändert gelassene Zellen melden sich beim Verlassen des Eingabefelds hier —
|
||||
/// gespeichert wird nur bei einer tatsächlichen Änderung.
|
||||
public void TrySetValue(double? value)
|
||||
{
|
||||
var changed = Value != value;
|
||||
Value = value;
|
||||
IsInvalid = IsOutOfRange(value);
|
||||
if (!IsInvalid) OnChanged?.Invoke();
|
||||
if (!IsInvalid && changed) OnChanged?.Invoke();
|
||||
}
|
||||
|
||||
private bool IsOutOfRange(double? value) =>
|
||||
|
||||
@@ -47,19 +47,33 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
[ObservableProperty] private string _repeatError = "";
|
||||
[ObservableProperty] private string _taskPointsError = "";
|
||||
[ObservableProperty] private string _kindName = ExamKind.Klausur.Label();
|
||||
[ObservableProperty] private bool _isMakeUp;
|
||||
[ObservableProperty] private string _followUpKindName = WiederholungName;
|
||||
|
||||
private const string WiederholungName = "Wiederholung";
|
||||
private const string NachschreibarbeitName = "Nachschreibarbeit";
|
||||
private const string FoerderarbeitName = "Förderarbeit";
|
||||
// Beim Befüllen aus einer vorhandenen Arbeit darf die Datums-Übernahme das gespeicherte Datum
|
||||
// nicht überschreiben.
|
||||
private bool _initializing = true;
|
||||
|
||||
public bool IsDifferentiated { get; }
|
||||
public string[] NiveauOptions => NiveauDisplay.Options;
|
||||
public string[] KindOptions { get; } = Enum.GetValues<ExamKind>().Select(k => k.Label()).ToArray();
|
||||
public string[] RepeatRuleOptions => ["Bessere zählt", "Wiederholung zählt", "Erste Arbeit zählt"];
|
||||
public string[] FollowUpKindOptions { get; } = [WiederholungName, NachschreibarbeitName, FoerderarbeitName];
|
||||
public bool IsMakeUp => FollowUpKindName == NachschreibarbeitName;
|
||||
public bool IsSupportWork => FollowUpKindName == FoerderarbeitName;
|
||||
public bool HasSelectedOriginalExam => SelectedOriginalExam is not null;
|
||||
/// Art und Zählweise sind bei einer verknüpften Folgearbeit festgelegt: sie erbt die Art der
|
||||
/// ersten Arbeit, eine Nachschreibarbeit ersetzt nur fehlende Noten (keine Regelwahl nötig).
|
||||
/// ersten Arbeit, Nachschreib- und Förderarbeiten ersetzen nur fehlende Noten (keine Regelwahl nötig).
|
||||
public bool CanChooseKind => !HasSelectedOriginalExam;
|
||||
public bool ShowRepeatRule => HasSelectedOriginalExam && !IsMakeUp;
|
||||
public string LinkedExamLabel => IsMakeUp
|
||||
? "Nachschreibarbeit zu (optional)" : "Wiederholung oder Nachschreibarbeit zu (optional)";
|
||||
public bool ShowRepeatRule => HasSelectedOriginalExam && FollowUpKindName == WiederholungName;
|
||||
public string LinkedExamLabel => FollowUpKindName switch
|
||||
{
|
||||
NachschreibarbeitName => "Nachschreibarbeit zu (optional)",
|
||||
FoerderarbeitName => "Förderarbeit zu (optional)",
|
||||
_ => "Wiederholung, Nachschreib- oder Förderarbeit zu (optional)",
|
||||
};
|
||||
private ExamKind Kind => Enum.GetValues<ExamKind>().FirstOrDefault(k => k.Label() == KindName);
|
||||
public ObservableCollection<Exam> AvailableOriginalExams { get; } = [];
|
||||
|
||||
@@ -128,7 +142,9 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
if (!isDuplicate)
|
||||
{
|
||||
SelectedOriginalExam = AvailableOriginalExams.FirstOrDefault(e => e.Id == source.RepeatOfExamId);
|
||||
IsMakeUp = source.IsMakeUp && SelectedOriginalExam is not null;
|
||||
if (SelectedOriginalExam is not null)
|
||||
FollowUpKindName = source.IsSupportWork ? FoerderarbeitName
|
||||
: source.IsMakeUp ? NachschreibarbeitName : WiederholungName;
|
||||
RepeatRuleName = source.RepeatGradeRule switch
|
||||
{
|
||||
RepeatGradeRule.Repeat => "Wiederholung zählt",
|
||||
@@ -152,6 +168,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
foreach (var t in Tasks) t.ShowWeight = UseWeighting;
|
||||
RecomputeTotals();
|
||||
UpdateWeekLoadHint();
|
||||
_initializing = false;
|
||||
}
|
||||
|
||||
partial void OnUseWeightingChanged(bool value)
|
||||
@@ -161,17 +178,29 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
partial void OnSelectedOriginalExamChanged(Exam? value)
|
||||
{
|
||||
if (value is not null) KindName = value.Kind.Label();
|
||||
else IsMakeUp = false;
|
||||
else FollowUpKindName = WiederholungName;
|
||||
OnPropertyChanged(nameof(HasSelectedOriginalExam));
|
||||
OnPropertyChanged(nameof(CanChooseKind));
|
||||
OnPropertyChanged(nameof(ShowRepeatRule));
|
||||
TakeOverOriginalDateForSupportWork();
|
||||
UpdateWeekLoadHint();
|
||||
}
|
||||
|
||||
partial void OnIsMakeUpChanged(bool value)
|
||||
partial void OnFollowUpKindNameChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsMakeUp));
|
||||
OnPropertyChanged(nameof(IsSupportWork));
|
||||
OnPropertyChanged(nameof(ShowRepeatRule));
|
||||
OnPropertyChanged(nameof(LinkedExamLabel));
|
||||
TakeOverOriginalDateForSupportWork();
|
||||
}
|
||||
|
||||
/// Eine Förderarbeit wird meist parallel zur ersten Arbeit geschrieben: das Datum der
|
||||
/// ersten Arbeit ist der Vorschlag, bleibt aber änderbar.
|
||||
private void TakeOverOriginalDateForSupportWork()
|
||||
{
|
||||
if (_initializing || !IsSupportWork || SelectedOriginalExam is null) return;
|
||||
DateText = SelectedOriginalExam.Date.ToString("dd.MM.yyyy");
|
||||
}
|
||||
|
||||
partial void OnKindNameChanged(string value) => UpdateWeekLoadHint();
|
||||
@@ -382,12 +411,25 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
var gradingKey = BuildGradingKeyEntries();
|
||||
var gradingKeyError = _grading.ValidateGradingKey(gradingKey);
|
||||
if (gradingKeyError is not null) { GradingKeyValidation = gradingKeyError; valid = false; }
|
||||
if (SelectedOriginalExam is not null && SelectedOriginalExam.Date >= date)
|
||||
// Datumsprüfung nur bei gültigem Datum — sonst meldet das Datumsfeld den Fehler bereits.
|
||||
// Eine Förderarbeit entsteht parallel zur ersten Arbeit und darf am selben Tag liegen.
|
||||
if (SelectedOriginalExam is not null && DateTextError == "")
|
||||
{
|
||||
RepeatError = IsMakeUp
|
||||
? "Die erste Arbeit muss vor der Nachschreibarbeit liegen."
|
||||
: "Die erste Arbeit muss vor der Wiederholung liegen.";
|
||||
valid = false;
|
||||
if (IsSupportWork)
|
||||
{
|
||||
if (SelectedOriginalExam.Date > date)
|
||||
{
|
||||
RepeatError = "Die Förderarbeit darf nicht vor der ersten Arbeit liegen.";
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
else if (SelectedOriginalExam.Date >= date)
|
||||
{
|
||||
RepeatError = IsMakeUp
|
||||
? "Die erste Arbeit muss vor der Nachschreibarbeit liegen."
|
||||
: "Die erste Arbeit muss vor der Wiederholung liegen.";
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
if (Tasks.Any(t => t.VariantName != "Beide") &&
|
||||
(TotalPoints <= 0 || Tasks.Where(t => !t.IsBonus && t.VariantName != "A").Sum(t => t.MaxPoints) <= 0))
|
||||
@@ -410,6 +452,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
Result.RepeatOfExamId = SelectedOriginalExam?.Id;
|
||||
Result.Kind = SelectedOriginalExam?.Kind ?? Kind;
|
||||
Result.IsMakeUp = IsMakeUp && SelectedOriginalExam is not null;
|
||||
Result.IsSupportWork = IsSupportWork && SelectedOriginalExam is not null;
|
||||
Result.RepeatGradeRule = RepeatRuleName switch
|
||||
{
|
||||
"Wiederholung zählt" => RepeatGradeRule.Repeat,
|
||||
|
||||
@@ -198,6 +198,8 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann
|
||||
// kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen.
|
||||
public bool IsDifferentiated => Group?.IsDifferentiated ?? false;
|
||||
/// In differenzierten Gruppen übernimmt das Förderniveau die Rolle der Markierung.
|
||||
public bool ShowSupportColumn => !IsDifferentiated;
|
||||
public bool IsReadOnly => Group is { IsActive: false };
|
||||
public bool IsEditable => Group is { IsActive: true };
|
||||
public string SubjectName { get; private set; } = "";
|
||||
@@ -205,6 +207,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
partial void OnGroupChanged(LearningGroup? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsDifferentiated));
|
||||
OnPropertyChanged(nameof(ShowSupportColumn));
|
||||
OnPropertyChanged(nameof(IsReadOnly));
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
NotifyWriteCommands();
|
||||
@@ -324,6 +327,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
var membership = _memberships.GetByStudentAndGroup(summary.Id, Group.Id);
|
||||
if (membership is null) return;
|
||||
membership.Niveau = summary.Niveau;
|
||||
membership.IsSupportStudent = summary.IsSupportStudent;
|
||||
_memberships.Save(membership);
|
||||
}
|
||||
|
||||
@@ -587,6 +591,7 @@ public partial class StudentSummary : ObservableObject
|
||||
public string AvatarTooltip { get; }
|
||||
|
||||
[ObservableProperty] private Niveau? _niveau;
|
||||
[ObservableProperty] private bool _isSupportStudent;
|
||||
|
||||
public string NiveauName
|
||||
{
|
||||
@@ -612,8 +617,11 @@ public partial class StudentSummary : ObservableObject
|
||||
? IsFormer ? $"Ausgetreten am {exitDate:dd.MM.yyyy}" : $"Austritt am {exitDate:dd.MM.yyyy}"
|
||||
: "Aktiv";
|
||||
_niveau = membership?.Niveau;
|
||||
_isSupportStudent = membership?.IsSupportStudent ?? false;
|
||||
}
|
||||
|
||||
partial void OnIsSupportStudentChanged(bool value) => OnChanged?.Invoke(this);
|
||||
|
||||
partial void OnNiveauChanged(Niveau? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(NiveauName));
|
||||
@@ -656,6 +664,7 @@ public class ExamSummary
|
||||
NiveauLabel = e.Niveau.HasValue ? NiveauDisplay.ToName(e.Niveau) : "alle";
|
||||
var hasVariants = e.Tasks.Any(t => t.Variant is not null);
|
||||
var followUp = e.RepeatOfExamId is null ? null
|
||||
: e.IsSupportWork ? (hasVariants ? "Förder · A/B" : "Förderarbeit")
|
||||
: e.IsMakeUp ? (hasVariants ? "Nachschr. · A/B" : "Nachschreiben")
|
||||
: hasVariants ? "Wdh. · A/B" : "Wiederholung";
|
||||
// Folgearbeiten behalten ihre Art im Hintergrund (sie erben sie), die Spalte nennt nur
|
||||
|
||||
@@ -33,7 +33,7 @@ public partial class PersonalizeWorksheetDialogViewModel : ObservableObject
|
||||
IStudentRepository students, WorksheetTemplateStore templates, ITemplateRenderer renderer)
|
||||
{
|
||||
_group = group; _templates = templates; _renderer = renderer;
|
||||
foreach (var template in templates.Store.GetTemplates().Where(x => !x.IsExamCover)) Templates.Add(new(template));
|
||||
foreach (var template in templates.Store.GetTemplates().Where(x => !x.IsExamCover && !x.IsStudentReport)) Templates.Add(new(template));
|
||||
SelectedTemplate = Templates.FirstOrDefault();
|
||||
foreach (var id in studentIds)
|
||||
if (students.GetById(id) is { } student) Students.Add(new(student));
|
||||
|
||||
@@ -789,6 +789,10 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
// Für die KI-Unterstützung mit Fokus auf genau diese Stunde (4.5.22) — nur für bereits
|
||||
// gespeicherte Stunden sinnvoll, eine gerade erst angelegte, noch ungespeicherte Stunde hat
|
||||
// keine echte Id, auf die sich die KI beziehen könnte.
|
||||
// Persist a pending parent only after validation and before writing its first lesson.
|
||||
public Action? BeforeSave { get; set; }
|
||||
public void EnsureParentSaved() => BeforeSave?.Invoke();
|
||||
|
||||
public Guid UnitId => _unitId;
|
||||
public Guid GroupId => _groupId;
|
||||
public Lesson? EditingLesson => _editingLesson;
|
||||
@@ -1079,6 +1083,7 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
EnsureParentSaved();
|
||||
Result = _editingLesson ?? new Lesson { UnitId = _unitId, GroupId = _groupId };
|
||||
Result.UnitId = _unitId;
|
||||
Result.GroupId = _groupId;
|
||||
|
||||
@@ -373,7 +373,7 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
|
||||
if (!ReportGradeCalculator.ExamApplies(exam, membership)) continue;
|
||||
var results = _calculator.ResultsByExam[exam.Id];
|
||||
results.TryGetValue(studentId, out var own);
|
||||
// Nachschreib- und Wiederholungsarbeiten betreffen nur einzelne Schüler — ohne eigenes
|
||||
// Nachschreib-, Förder- und Wiederholungsarbeiten betreffen nur einzelne Schüler — ohne eigenes
|
||||
// Ergebnis gehören sie nicht in diese Übersicht.
|
||||
if (own is null && exam.RepeatOfExamId.HasValue) continue;
|
||||
|
||||
@@ -396,7 +396,8 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
|
||||
var classAverage = classGrades.Count > 0 ? $"Ø Kurs {Format(_grading.WeightedAverage(classGrades))}" : "";
|
||||
|
||||
var kind = exam.Kind.Label();
|
||||
if (exam.IsMakeUp) kind += " · Nachschreibarbeit";
|
||||
if (exam.IsSupportWork) kind += " · Förderarbeit";
|
||||
else if (exam.IsMakeUp) kind += " · Nachschreibarbeit";
|
||||
else if (exam.RepeatOfExamId.HasValue) kind += " · Wiederholung";
|
||||
|
||||
Works.Add(new AssistantWorkCard(exam.Title, exam.Date, kind, gradeDisplay, percentDisplay,
|
||||
|
||||
@@ -1,54 +1,115 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── KI-Unterstützung (4.5.9) ──────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private bool _aiEnabled;
|
||||
[ObservableProperty] private bool _aiEnabled;
|
||||
[ObservableProperty] private string _aiUsername = "";
|
||||
[ObservableProperty] private string _aiPassword = "";
|
||||
[ObservableProperty] private string _aiLoginError = "";
|
||||
[ObservableProperty] private bool _aiIsLoggedIn;
|
||||
[ObservableProperty] private bool _aiIsLoggedIn;
|
||||
[ObservableProperty] private string _aiBalanceDisplay = "";
|
||||
[ObservableProperty] private bool _aiIsRefreshing;
|
||||
[ObservableProperty] private string _aiAccountStatus = "";
|
||||
[ObservableProperty] private string _aiUsageSummary = "";
|
||||
[ObservableProperty] private string _aiUsageStatus = "Nach der Anmeldung erscheint hier der Verbrauch der letzten vier Wochen.";
|
||||
[ObservableProperty] private string _aiUsagePeriod = "";
|
||||
[ObservableProperty] private bool _aiHasUsage;
|
||||
private int _aiRefreshVersion;
|
||||
|
||||
// ── KI-Unterstützung: Laden / Anmelden / Abmelden ────────────────────────
|
||||
public ObservableCollection<AiFeatureItem> AiFeatures { get; } =
|
||||
[
|
||||
new("plan", "Unterricht planen", "Einheiten und einzelne Stunden entwerfen oder überarbeiten; Vorschläge vor der Übernahme prüfen."),
|
||||
new("explain", "Didaktik erklären", "Didaktische Erläuterungen zu einer vorgeschlagenen Unterrichtsstunde abrufen."),
|
||||
new("gbu", "Gefährdungsbeurteilung", "Einen GBU-Entwurf zum geplanten Experiment erstellen und im Assistenten weiterbearbeiten."),
|
||||
new("untis-status", "Fehlzeiten prüfen", "Vorschläge für den Entschuldigungsstatus beim WebUntis-Abgleich erhalten."),
|
||||
new(null, "Stoffdaten nachschlagen", "Stoffe im GBU-Assistenten in der RiSU-Stoffliste suchen. Keine KI, kein Guthabenverbrauch.", "Kostenlos")
|
||||
];
|
||||
public ObservableCollection<AiUsageGroupItem> AiUsageGroups { get; } = [];
|
||||
public ObservableCollection<AiUsageEntryItem> AiUsageEntries { get; } = [];
|
||||
|
||||
private void LoadAiSettings()
|
||||
{
|
||||
AiEnabled = _aiSettings.Enabled;
|
||||
AiUsername = _aiSettings.Username;
|
||||
AiIsLoggedIn = _aiSettings.IsLoggedIn;
|
||||
if (AiIsLoggedIn) _ = RefreshAiBalance();
|
||||
if (AiIsLoggedIn) _ = RefreshAiAccount();
|
||||
}
|
||||
|
||||
partial void OnAiEnabledChanged(bool value) => _aiSettings.SetEnabled(value);
|
||||
|
||||
private async Task RefreshAiBalance()
|
||||
[RelayCommand]
|
||||
private async Task RefreshAiAccount()
|
||||
{
|
||||
var token = _aiSettings.GetToken();
|
||||
if (token is null) return;
|
||||
var version = ++_aiRefreshVersion;
|
||||
bool Current() => version == _aiRefreshVersion && token == _aiSettings.GetToken();
|
||||
AiIsRefreshing = true;
|
||||
AiAccountStatus = "";
|
||||
AiBalanceDisplay = "Guthaben wird geladen …";
|
||||
ClearAiUsage("Verbrauch wird geladen …");
|
||||
try
|
||||
{
|
||||
var balance = await _aiPlanning.GetBalanceAsync(token);
|
||||
AiBalanceDisplay = $"Guthaben: {balance:0.00} €";
|
||||
// Die Übersicht bleibt nutzbar, wenn ein älteres Backend nur das Guthaben liefert.
|
||||
try
|
||||
{
|
||||
var balance = await _aiPlanning.GetAccountBalanceAsync(token);
|
||||
if (!Current()) return;
|
||||
AiBalanceDisplay = "Guthaben: " + AiMoney.Format(balance.BalanceUsd, balance.BalanceEur);
|
||||
}
|
||||
catch (AiBackendException ex)
|
||||
{
|
||||
if (!Current()) return;
|
||||
AiBalanceDisplay = "Guthaben nicht verfügbar";
|
||||
AiAccountStatus = ex.Message;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var usage = await _aiPlanning.GetUsageAsync(token);
|
||||
if (!Current()) return;
|
||||
AiUsageStatus = usage.Calls == 0 ? $"In den letzten {usage.Days} Tagen wurde nichts berechnet." : "";
|
||||
AiUsagePeriod = $"{usage.From:dd.MM.yyyy HH:mm} bis {usage.To:dd.MM.yyyy HH:mm} (Backend-Zeit)";
|
||||
AiUsageSummary = $"Gesamt: {AiMoney.Format(usage.ChargedUsd, usage.ChargedEur)} · {usage.Calls} Aufrufe";
|
||||
foreach (var group in usage.ByAction.OrderByDescending(g => g.ChargedUsd))
|
||||
AiUsageGroups.Add(new(group.Label, group.Calls, AiMoney.Format(group.ChargedUsd, group.ChargedEur),
|
||||
usage.ChargedUsd > 0 ? (double)(100 * group.ChargedUsd / usage.ChargedUsd) : 0));
|
||||
foreach (var entry in usage.Entries.OrderByDescending(e => e.At))
|
||||
AiUsageEntries.Add(new(entry.At.ToString("dd.MM.yyyy HH:mm"), entry.Label,
|
||||
AiMoney.Format(entry.ChargedUsd, entry.ChargedEur)));
|
||||
foreach (var feature in AiFeatures.Where(f => f.ActionId is not null))
|
||||
{
|
||||
var groups = usage.ByAction.Where(g => g.ActionId == feature.ActionId).ToArray();
|
||||
feature.Usage = groups.Length == 0 ? "Keine berechneten Aufrufe" :
|
||||
$"{groups.Sum(g => g.Calls)}× · {AiMoney.Format(groups.Sum(g => g.ChargedUsd), groups.All(g => g.ChargedEur.HasValue) ? groups.Sum(g => g.ChargedEur!.Value) : null)}";
|
||||
}
|
||||
AiHasUsage = usage.Calls > 0;
|
||||
if (usage.EntriesTruncated)
|
||||
AiUsageStatus = $"Die Liste zeigt die neuesten {usage.Entries.Count} Aufrufe. Die Summen enthalten alle Aufrufe im Zeitraum.";
|
||||
}
|
||||
catch (AiBackendException ex)
|
||||
{
|
||||
if (Current()) AiUsageStatus = ex.Message;
|
||||
}
|
||||
}
|
||||
catch (AiBackendException ex) { AiBalanceDisplay = ex.Message; }
|
||||
finally { if (Current()) AiIsRefreshing = false; }
|
||||
}
|
||||
|
||||
private void ClearAiUsage(string status)
|
||||
{
|
||||
AiUsageGroups.Clear();
|
||||
AiUsageEntries.Clear();
|
||||
AiHasUsage = false;
|
||||
AiUsageSummary = "";
|
||||
AiUsagePeriod = "";
|
||||
AiUsageStatus = status;
|
||||
foreach (var feature in AiFeatures.Where(f => f.ActionId is not null)) feature.Usage = "Verbrauch nicht verfügbar";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -64,18 +125,45 @@ public partial class SettingsViewModel
|
||||
{
|
||||
var token = await _aiPlanning.LoginAsync(AiUsername, AiPassword);
|
||||
_aiSettings.SetCredentialsAndToken(AiUsername, token);
|
||||
AiPassword = "";
|
||||
AiIsLoggedIn = true;
|
||||
await RefreshAiBalance();
|
||||
await RefreshAiAccount();
|
||||
}
|
||||
catch (AiBackendException ex) { AiLoginError = ex.Message; }
|
||||
finally { AiPassword = ""; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AiLogout()
|
||||
{
|
||||
++_aiRefreshVersion;
|
||||
_aiSettings.Logout();
|
||||
AiIsLoggedIn = false;
|
||||
AiIsRefreshing = false;
|
||||
AiBalanceDisplay = "";
|
||||
AiAccountStatus = "";
|
||||
AiLoginError = "";
|
||||
AiPassword = "";
|
||||
ClearAiUsage("Nach der Anmeldung erscheint hier der Verbrauch der letzten vier Wochen.");
|
||||
}
|
||||
}
|
||||
|
||||
public partial class AiFeatureItem(string? actionId, string name, string description, string usage = "Verbrauch nach Anmeldung") : ObservableObject
|
||||
{
|
||||
public string? ActionId { get; } = actionId;
|
||||
public string Name { get; } = name;
|
||||
public string Description { get; } = description;
|
||||
[ObservableProperty] private string _usage = usage;
|
||||
}
|
||||
|
||||
public sealed record AiUsageGroupItem(string Label, int Calls, string Amount, double Percent)
|
||||
{
|
||||
public string CallsDisplay => $"{Calls}×";
|
||||
}
|
||||
public sealed record AiUsageEntryItem(string When, string Label, string Amount);
|
||||
|
||||
internal static class AiMoney
|
||||
{
|
||||
public static string Format(decimal usd, decimal? eur) => Number(usd) + " USD" +
|
||||
(eur is { } value ? $" (≈ {Number(value)} €)" : "");
|
||||
private static string Number(decimal value) => value.ToString(value != 0 && Math.Abs(value) < 0.1m ? "0.000000" : "0.00", CultureInfo.GetCultureInfo("de-DE"));
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
_attendanceCalendarFactory = attendanceCalendarFactory;
|
||||
_absenceDayListFactory = absenceDayListFactory;
|
||||
_attendanceDataRefresher = attendanceDataRefresher;
|
||||
foreach (var template in templates.GetTemplates().Where(x => !x.IsExamCover)) Templates.Add(new(template));
|
||||
foreach (var template in templates.GetTemplates().Where(x => !x.IsExamCover && !x.IsStudentReport)) Templates.Add(new(template));
|
||||
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
|
||||
foreach (var membership in memberships.GetByStudent(student.Id))
|
||||
if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group));
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
public partial class StudentReportDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly Student _student;
|
||||
private readonly StudentReportService _reports;
|
||||
private readonly ITemplateRenderer _renderer;
|
||||
private readonly IInterimGradeRepository _interims;
|
||||
private readonly SchoolYearService _schoolYears;
|
||||
private readonly TemplateStore _loader;
|
||||
public string StudentName => _student.FullName;
|
||||
public string SuggestedFileName => string.Concat($"Schülerbericht_{_student.LastName}_{_student.FirstName}.pdf"
|
||||
.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
|
||||
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
|
||||
public ObservableCollection<StudentReportTemplateChoice> Templates { get; } = [];
|
||||
public ObservableCollection<StudentReportInterimChoice> Interims { get; } = [];
|
||||
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
||||
[ObservableProperty] private StudentReportTemplateChoice? _selectedTemplate;
|
||||
[ObservableProperty] private StudentReportInterimChoice? _selectedInterim;
|
||||
[ObservableProperty] private string _from = "";
|
||||
[ObservableProperty] private string _to = DateTime.Today.ToString("dd.MM.yyyy");
|
||||
[ObservableProperty] private bool _includeInterim = true;
|
||||
[ObservableProperty] private bool _includeParticipation = true;
|
||||
[ObservableProperty] private bool _includeCompetencies = true;
|
||||
[ObservableProperty] private bool _includeHomework = true;
|
||||
[ObservableProperty] private bool _includeDocumentation = true;
|
||||
[ObservableProperty] private bool _includeConfidential;
|
||||
[ObservableProperty] private string _comment = "";
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
[ObservableProperty, NotifyPropertyChangedFor(nameof(CanGenerate))] private bool _isGenerating;
|
||||
public bool CanGenerate => !IsGenerating && SelectedGroup is not null && SelectedTemplate is not null;
|
||||
|
||||
public StudentReportDialogViewModel(Student student, IGroupMembershipRepository memberships,
|
||||
IGroupRepository groups, IInterimGradeRepository interims, StudentReportService reports,
|
||||
ITemplateRenderer renderer, SchoolYearService schoolYears, IReadOnlyList<TemplateStore> stores)
|
||||
{
|
||||
_student = student; _interims = interims; _reports = reports; _renderer = renderer;
|
||||
_schoolYears = schoolYears; _loader = stores[0];
|
||||
Templates.Add(new(null));
|
||||
foreach (var template in stores.SelectMany(s => s.GetTemplates()).Where(t => t.IsStudentReport)
|
||||
.DistinctBy(t => t.PackagePath).OrderBy(t => t.Name)) Templates.Add(new(template));
|
||||
foreach (var membership in memberships.GetByStudent(student.Id))
|
||||
if (groups.GetById(membership.GroupId) is { } group && !Groups.Any(g => g.Model.Id == group.Id))
|
||||
Groups.Add(new(group));
|
||||
SelectedTemplate = Templates[0];
|
||||
SelectedGroup = Groups.OrderByDescending(g => g.Model.SchoolYear).FirstOrDefault();
|
||||
}
|
||||
|
||||
partial void OnSelectedGroupChanged(LetterGroupChoice? value)
|
||||
{
|
||||
Interims.Clear(); SelectedInterim = null;
|
||||
if (value is not null)
|
||||
{
|
||||
From = _schoolYears.SchoolYearStart(value.Model.SchoolYear).ToString("dd.MM.yyyy");
|
||||
To = _schoolYears.SchoolYearEnd(value.Model.SchoolYear).ToString("dd.MM.yyyy");
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
if (_schoolYears.SchoolYearEnd(value.Model.SchoolYear) > today) To = today.ToString("dd.MM.yyyy");
|
||||
foreach (var grade in _interims.GetByStudentAndGroup(_student.Id, value.Model.Id)
|
||||
.OrderByDescending(g => g.Date).ThenBy(g => g.Occasion)) Interims.Add(new(grade));
|
||||
}
|
||||
OnPropertyChanged(nameof(CanGenerate));
|
||||
}
|
||||
partial void OnSelectedTemplateChanged(StudentReportTemplateChoice? value) => OnPropertyChanged(nameof(CanGenerate));
|
||||
|
||||
public async Task<bool> GenerateAsync(string outputPath)
|
||||
{
|
||||
if (!CanGenerate) return false;
|
||||
StatusMessage = "";
|
||||
if (!DateOnly.TryParseExact(From, "dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out var from)
|
||||
|| !DateOnly.TryParseExact(To, "dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out var to))
|
||||
{ StatusMessage = "Bitte Beginn und Ende als TT.MM.JJJJ eingeben."; return false; }
|
||||
try
|
||||
{
|
||||
var template = SelectedTemplate!.Model is { } installed ? _loader.Load(installed) : StudentReportTemplate.Create();
|
||||
// Datenbankzugriffe auf dem UI-Thread; nur das Setzen und Schreiben läuft im Hintergrund.
|
||||
var values = _reports.Build(_student, SelectedGroup!.Model, template,
|
||||
new(from, to, SelectedInterim?.Model, IncludeInterim, IncludeParticipation, IncludeCompetencies,
|
||||
IncludeHomework, IncludeDocumentation, IncludeConfidential, Comment));
|
||||
IsGenerating = true;
|
||||
await Task.Run(() => File.WriteAllBytes(outputPath, _renderer.RenderToPdf(template, new LetterDataProvider(values))));
|
||||
StatusMessage = "Schülerbericht erzeugt.";
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException or QuestPDF.Drawing.Exceptions.DocumentLayoutException)
|
||||
{ StatusMessage = $"Der Schülerbericht konnte nicht erzeugt werden: {ex.Message}"; return false; }
|
||||
finally { IsGenerating = false; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record StudentReportTemplateChoice(InstalledTemplate? Model)
|
||||
{
|
||||
public string Name => Model?.Name ?? "Schülerbericht Standard";
|
||||
}
|
||||
public sealed record StudentReportInterimChoice(InterimGrade Model)
|
||||
{
|
||||
public string Label => $"{Model.Date:dd.MM.yyyy} · {Model.Occasion}";
|
||||
}
|
||||
@@ -276,8 +276,14 @@ public partial class StudentDetailViewModel : ObservableObject
|
||||
public Func<Documentation, string, Task<Documentation?>>? OnConductParentCall { get; set; }
|
||||
public Func<Student, Task<StudentManagementResult>>? OnManageStudent { get; set; }
|
||||
public Func<Task>? OnCreateLetter { get; set; }
|
||||
public Func<Task>? OnCreateStudentReport { get; set; }
|
||||
public Action? OnReturnToStudentList { get; set; }
|
||||
|
||||
partial void OnActiveTabIndexChanged(int value)
|
||||
{
|
||||
if (Student is null) return;
|
||||
if (value == VorgaengeTabIndex) VorgaengeTab.Load(Student.Id);
|
||||
}
|
||||
partial void OnIsStudentActiveChanged(bool value) => OnPropertyChanged(nameof(StudentStatus));
|
||||
|
||||
public StudentDetailViewModel(IStudentRepository students,
|
||||
@@ -455,6 +461,12 @@ public partial class StudentDetailViewModel : ObservableObject
|
||||
|
||||
[RelayCommand] private void StartEdit() => IsEditing = true;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateStudentReport()
|
||||
{
|
||||
if (Student is not null && OnCreateStudentReport is not null) await OnCreateStudentReport();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateLetter()
|
||||
{
|
||||
|
||||
@@ -158,9 +158,12 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding HasAvailableDocumentation}">
|
||||
<TextBlock Text="Bestehende Dokumentation der Schüler*innen verknüpfen" FontSize="12"
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Vorhandenen Akteneintrag verknüpfen" FontSize="12"
|
||||
FontWeight="SemiBold" Opacity="0.7"/>
|
||||
<TextBlock Text="Keine weiteren passenden Akteneinträge vorhanden. Neue Einträge aus der Schülerakte werden beim erneuten Öffnen des Tabs geladen."
|
||||
FontSize="11" Opacity="0.6" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasAvailableDocumentation}"/>
|
||||
<ItemsControl ItemsSource="{Binding AvailableDocumentation}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="svm:DocumentationItem">
|
||||
|
||||
@@ -432,7 +432,7 @@
|
||||
HorizontalAlignment="Stretch"
|
||||
AutomationProperties.Name="Schüler für Monatskalender auswählen"/>
|
||||
<shared:CompactMonthCalendar Days="{Binding CompactMonthDays}"/>
|
||||
<TextBlock Text="U unentsch. · ! Klassenbuch · A abwesend · V verspätet · E entsch."
|
||||
<TextBlock Text="U/E/A ganzer Fehltag · T Teilfehlzeit · G früher gegangen · V verspätet · ! Klassenbuch. Rot: unentschuldigt."
|
||||
FontSize="9" Opacity="0.5" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.ExamCoverSheetDialog"
|
||||
x:DataType="vm:ExamCoverSheetDialogViewModel"
|
||||
Title="Deckblätter drucken"
|
||||
Width="460" Height="640"
|
||||
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||
Width="460" Height="740" MinWidth="440" MinHeight="500"
|
||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Margin="24">
|
||||
|
||||
@@ -21,11 +21,22 @@
|
||||
<TextBlock Text="Noch keine Deckblatt-Vorlage importiert — in den Einstellungen unter „Briefvorlagen“ eine .lavorlage-Datei mit dem Metadatum report-type=exam-cover hinzufügen."
|
||||
Classes="emptyhint" TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasNoTemplates}"/>
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
|
||||
<TextBlock Grid.Column="0" Text="Kürzel" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding TeacherAbbreviation}" PlaceholderText="z. B. MON HED"
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto" ColumnSpacing="12" RowSpacing="10">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Titel" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding CoverTitle}" PlaceholderText="{Binding ExamTitle}"
|
||||
ToolTip.Tip="Vorbelegt mit dem Titel der Klausur. Änderungen gelten nur für diesen Druck; leer gelassen steht der Klausurtitel auf dem Deckblatt."/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Kürzel" VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding TeacherAbbreviation}" PlaceholderText="z. B. MON HED"
|
||||
ToolTip.Tip="Vorbelegt mit dem eigenen Kürzel aus den Einstellungen. Änderungen gelten nur für diesen Druck."/>
|
||||
</Grid>
|
||||
<StackPanel Spacing="4" IsVisible="{Binding UsesInterimGrades}">
|
||||
<TextBlock Text="Gespeicherter Notenzwischenstand" FontWeight="SemiBold"/>
|
||||
<ComboBox ItemsSource="{Binding InterimRuns}" SelectedItem="{Binding SelectedInterimRun}"
|
||||
DisplayMemberBinding="{Binding Label}" HorizontalAlignment="Stretch"
|
||||
PlaceholderText="Datum und Anlass auswählen"/>
|
||||
<TextBlock Text="Für alle ausgewählten Schüler muss in diesem Durchgang ein Zwischenstand vorliegen."
|
||||
TextWrapping="Wrap" FontSize="12" Opacity="0.65"/>
|
||||
</StackPanel>
|
||||
<CheckBox Content="Punkte und Note eintragen" IsChecked="{Binding FillResults}"
|
||||
ToolTip.Tip="Aus: Blanko-Deckblatt mit Namen und leerer Punktetabelle zum Ausfüllen von Hand."/>
|
||||
</StackPanel>
|
||||
@@ -46,13 +57,21 @@
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" FontSize="12" Margin="0,10,0,0" TextWrapping="Wrap"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<StackPanel Grid.Row="3">
|
||||
<StackPanel Spacing="6" Margin="0,10,0,0" IsVisible="{Binding IsGenerating}">
|
||||
<ProgressBar Minimum="0" Maximum="{Binding ProgressMaximum}" Value="{Binding ProgressValue}"
|
||||
IsIndeterminate="{Binding IsMerging}" Height="6"/>
|
||||
<TextBlock Text="{Binding ProgressText}" FontSize="12" Opacity="0.65"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding StatusMessage}" FontSize="12" Margin="0,10,0,0" TextWrapping="Wrap"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="4" ColumnDefinitions="*,8,*" Margin="0,16,0,0">
|
||||
<Button Grid.Column="0" Content="Schließen" HorizontalAlignment="Stretch" Click="OnClose"/>
|
||||
<Button Grid.Column="0" Content="Schließen" HorizontalAlignment="Stretch" Click="OnClose"
|
||||
IsEnabled="{Binding !IsGenerating}"/>
|
||||
<Button Grid.Column="2" Content="PDF erzeugen…" HorizontalAlignment="Stretch" Click="OnGenerate"
|
||||
IsEnabled="{Binding !HasNoTemplates}"/>
|
||||
IsEnabled="{Binding CanGenerate}"/>
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -7,7 +7,13 @@ namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class ExamCoverSheetDialog : Window
|
||||
{
|
||||
public ExamCoverSheetDialog() => InitializeComponent();
|
||||
public ExamCoverSheetDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
// Solange das PDF entsteht, bleibt der Dialog offen — sonst liefe die Erzeugung ohne
|
||||
// sichtbare Rückmeldung weiter.
|
||||
Closing += (_, e) => e.Cancel = DataContext is ExamCoverSheetDialogViewModel { IsGenerating: true };
|
||||
}
|
||||
|
||||
private async void OnGenerate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
@@ -21,7 +27,7 @@ public partial class ExamCoverSheetDialog : Window
|
||||
});
|
||||
if (file is null) return;
|
||||
var path = file.Path.LocalPath;
|
||||
if (vm.Generate(path))
|
||||
if (await vm.GenerateAsync(path))
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +76,9 @@
|
||||
<Button Grid.Column="1" Content="Auswahl löschen" Command="{Binding ClearOriginalExamCommand}"
|
||||
IsVisible="{Binding HasSelectedOriginalExam}"/>
|
||||
</Grid>
|
||||
<CheckBox Content="Nachschreibarbeit für Schüler, die die erste Arbeit versäumt haben"
|
||||
IsChecked="{Binding IsMakeUp}" IsVisible="{Binding HasSelectedOriginalExam}"
|
||||
ToolTip.Tip="Inhaltlich eigene Arbeit nur für Fehlende. Die Note ersetzt die fehlende Note der ersten Arbeit; die Punkteeingabe zeigt nur die Schüler, die die erste Arbeit nicht mitgeschrieben haben. Ohne Haken gilt es als Wiederholung für alle."/>
|
||||
<ComboBox ItemsSource="{Binding FollowUpKindOptions}" SelectedItem="{Binding FollowUpKindName}"
|
||||
IsVisible="{Binding HasSelectedOriginalExam}" HorizontalAlignment="Stretch"
|
||||
ToolTip.Tip="Wiederholung: Arbeit für alle, es zählt die gewählte Regel. Nachschreibarbeit: inhaltlich eigene Arbeit nur für Schüler, die die erste Arbeit versäumt haben. Förderarbeit: inhaltlich eigene Arbeit für die als Förderschüler markierten Schüler der Gruppe, die sie statt der ersten Arbeit schreiben (darf am selben Tag liegen; das Datum der ersten Arbeit wird vorgeschlagen). Bei Nachschreib- und Förderarbeit ersetzt die Note eine fehlende Note der ersten Arbeit, und die Punkteeingabe blendet Schüler mit vorhandener Note der ersten Arbeit aus."/>
|
||||
<ComboBox ItemsSource="{Binding RepeatRuleOptions}" SelectedItem="{Binding RepeatRuleName}"
|
||||
IsVisible="{Binding ShowRepeatRule}"/>
|
||||
<TextBlock Text="{Binding RepeatError}" Foreground="Red" FontSize="11"
|
||||
|
||||
@@ -96,6 +96,16 @@
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTemplateColumn Header="Förder" Width="80"
|
||||
IsVisible="{Binding ShowSupportColumn}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate x:DataType="vm:StudentSummary">
|
||||
<CheckBox IsChecked="{Binding IsSupportStudent}" IsEnabled="{Binding CanEdit}"
|
||||
HorizontalAlignment="Center" Margin="2"
|
||||
ToolTip.Tip="Förderschüler: bekommt eigenes Fördermaterial. Förderarbeiten zeigen in der Punkteeingabe nur markierte Schüler."/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -265,6 +265,7 @@ public partial class LessonDialog : Window
|
||||
chosen = picked;
|
||||
}
|
||||
|
||||
vm.EnsureParentSaved();
|
||||
chosen.UnitId = vm.UnitId;
|
||||
chosen.Date = date;
|
||||
chosen.LessonNumber = vm.LessonNumber;
|
||||
|
||||
@@ -121,10 +121,14 @@ public partial class TimetableView : UserControl
|
||||
App.Services.GetRequiredService<IAttachmentStorage>(),
|
||||
unit.Id, group.Id, group.Name, subject?.Name ?? "", materials, shorthands,
|
||||
editingLesson: null, suggestedDate: request.Date, suggestedPeriod: request.PeriodNumber);
|
||||
if (pickerVm.ResultIsNew)
|
||||
lessonVm.BeforeSave = () =>
|
||||
{
|
||||
if (units.GetById(unit.Id) is null) units.Save(unit);
|
||||
};
|
||||
var lessonDialog = new LessonDialog { DataContext = lessonVm };
|
||||
if (await lessonDialog.ShowDialog<bool>(owner) && lessonVm.Result is not null)
|
||||
{
|
||||
if (pickerVm.ResultIsNew) units.Save(unit);
|
||||
(DataContext as TimetableViewModel)?.Load();
|
||||
App.Services.GetRequiredService<NotificationService>().ShowSuccess("Stunde angelegt.");
|
||||
}
|
||||
|
||||
@@ -1063,15 +1063,15 @@
|
||||
<!-- Tab: KI-Unterstützung (4.5.9) -->
|
||||
<ContentPage Header="KI-Unterstützung">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
|
||||
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="820">
|
||||
|
||||
<TextBlock Text="KI-gestützte Planungsunterstützung" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="KI-Zugang und Verbrauch" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Ermöglicht KI-Vorschläge für Unterrichtseinheiten über einen Zwischendienst auf dem eigenen Server (kein API-Schlüssel im Client). Jede Anfrage verbraucht Guthaben."/>
|
||||
Text="KI-Anfragen werden über deinen Zugang am gemeinsamen Backend abgerechnet. Ein Klick auf die jeweilige KI-Aktion startet die Anfrage und kann Guthaben verbrauchen; es gibt keine zusätzliche Kostenbestätigung. Vorschläge prüfst du vor der Übernahme."/>
|
||||
|
||||
<CheckBox Content="KI-Unterstützung aktivieren" IsChecked="{Binding AiEnabled}"/>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding !AiIsLoggedIn}">
|
||||
<StackPanel Spacing="8" MaxWidth="420" IsVisible="{Binding !AiIsLoggedIn}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding AiUsername}"/>
|
||||
@@ -1090,9 +1090,71 @@
|
||||
<Run Text="Angemeldet als: "/><Run Text="{Binding AiUsername}"/>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding AiBalanceDisplay}" FontSize="13"/>
|
||||
<Button Content="Abmelden" Command="{Binding AiLogoutCommand}" HorizontalAlignment="Left"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="Aktualisieren" Command="{Binding RefreshAiAccountCommand}" IsEnabled="{Binding !AiIsRefreshing}"/>
|
||||
<Button Content="Abmelden" Command="{Binding AiLogoutCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<ProgressBar IsIndeterminate="True" IsVisible="{Binding AiIsRefreshing}" Height="3"/>
|
||||
<TextBlock Text="{Binding AiAccountStatus}" TextWrapping="Wrap" FontSize="12"
|
||||
IsVisible="{Binding AiAccountStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
|
||||
<TextBlock Text="Funktionen in der LehrerApp" FontSize="16" FontWeight="SemiBold" Margin="0,12,0,0"/>
|
||||
<TextBlock Text="Die Übersicht zeigt die eingebauten Funktionen und ihren Verbrauch in den letzten vier Wochen. Verfügbarkeit und Abrechnung hängen vom Backend und deinem Guthaben ab. Die Stoffsuche nutzt den angemeldeten Zugang auch ohne KI."
|
||||
TextWrapping="Wrap" FontSize="12" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding AiFeatures}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AiFeatureItem">
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderThickness="0,0,0,1" Padding="0,10">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="12" Opacity="0.8"/>
|
||||
<TextBlock Text="{Binding Usage}" TextWrapping="Wrap" FontSize="12" Opacity="0.65"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock Text="Verbrauch der letzten vier Wochen" FontSize="16" FontWeight="SemiBold" Margin="0,12,0,0"/>
|
||||
<TextBlock Text="{Binding AiUsagePeriod}" FontSize="12" Opacity="0.7" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding AiUsageSummary}" FontWeight="SemiBold" TextWrapping="Wrap" IsVisible="{Binding AiIsLoggedIn}"/>
|
||||
<TextBlock Text="{Binding AiUsageStatus}" FontSize="12" TextWrapping="Wrap"
|
||||
IsVisible="{Binding AiUsageStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<ItemsControl ItemsSource="{Binding AiUsageGroups}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AiUsageGroupItem">
|
||||
<StackPanel Spacing="5" Margin="0,0,0,12">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Text="{Binding Label}" TextWrapping="Wrap" Margin="0,0,12,0"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding CallsDisplay}" Opacity="0.7" Margin="0,0,12,0"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding Amount}"/>
|
||||
</Grid>
|
||||
<ProgressBar Value="{Binding Percent}" Maximum="100" Height="5"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Einzelne Aufrufe (neueste zuerst)" FontWeight="SemiBold" IsVisible="{Binding AiHasUsage}"/>
|
||||
<ScrollViewer MaxHeight="280" HorizontalScrollBarVisibility="Disabled" IsVisible="{Binding AiHasUsage}">
|
||||
<ItemsControl ItemsSource="{Binding AiUsageEntries}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AiUsageEntryItem">
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderThickness="0,0,0,1" Padding="0,7">
|
||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Text="{Binding Label}" TextWrapping="Wrap" Margin="0,0,12,0"/>
|
||||
<TextBlock Grid.Row="1" Text="{Binding When}" FontSize="11" Opacity="0.65"/>
|
||||
<TextBlock Grid.Column="1" Grid.RowSpan="2" Text="{Binding Amount}" FontSize="12" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
<TextBlock Text="Enthalten sind berechnete Aufrufe aller Apps mit diesem Zugang, auch des ArbeitsblattDesigners. Kostenlose Anfragen erscheinen nicht. Zeiten entsprechen der Backend-Uhr. Euro-Werte erscheinen nur, wenn sie vom Backend mitgeliefert werden."
|
||||
IsVisible="{Binding AiIsLoggedIn}" TextWrapping="Wrap" FontSize="12" Opacity="0.65"/>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
|
||||
<TextBlock Text="{Binding StudentStatus}" VerticalAlignment="Center" Opacity="0.65"/>
|
||||
<Button Content="Schülerbericht" Command="{Binding CreateStudentReportCommand}"
|
||||
IsVisible="{Binding !IsEditing}"/>
|
||||
<Button Content="PDF-Brief" Command="{Binding CreateLetterCommand}"
|
||||
IsVisible="{Binding !IsEditing}"/>
|
||||
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
|
||||
|
||||
@@ -27,6 +27,7 @@ public partial class StudentDetailView : UserControl
|
||||
vm.OnConductParentCall = ShowParentCallSessionDialog;
|
||||
vm.OnManageStudent = ShowManageStudentDialog;
|
||||
vm.OnCreateLetter = ShowCreateLetterDialog;
|
||||
vm.OnCreateStudentReport = ShowStudentReportDialog;
|
||||
}
|
||||
|
||||
private async Task<Contact?> ShowContactDialog(Contact? contact)
|
||||
@@ -58,6 +59,22 @@ public partial class StudentDetailView : UserControl
|
||||
await LetterDialogs.ShowCreateLetterDialogAsync(owner, student);
|
||||
}
|
||||
|
||||
private async Task ShowStudentReportDialog()
|
||||
{
|
||||
if (TopLevel.GetTopLevel(this) is not Window owner ||
|
||||
DataContext is not StudentDetailViewModel { Student: { } student }) return;
|
||||
var vm = new StudentReportDialogViewModel(student,
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<IGroupRepository>(),
|
||||
App.Services.GetRequiredService<IInterimGradeRepository>(),
|
||||
App.Services.GetRequiredService<StudentReportService>(),
|
||||
App.Services.GetRequiredService<LehrerApp.Templating.ITemplateRenderer>(),
|
||||
App.Services.GetRequiredService<LehrerApp.Core.Services.SchoolYearService>(),
|
||||
[App.Services.GetRequiredService<LehrerApp.Templating.TemplateStore>(),
|
||||
App.Services.GetRequiredService<LehrerApp.Templating.WorksheetTemplateStore>().Store]);
|
||||
await new StudentReportDialog { DataContext = vm }.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private void ShowAddressViewer(ContactItem contact)
|
||||
{
|
||||
if (!contact.HasAddress) return;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||
x:Class="LehrerApp.Desktop.Views.Students.StudentReportDialog"
|
||||
x:DataType="vm:StudentReportDialogViewModel"
|
||||
Title="Schülerbericht" Width="560" Height="760" MinWidth="460" MinHeight="500"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="Auto,*,Auto" Margin="24">
|
||||
<StackPanel Spacing="4" Margin="0,0,0,16">
|
||||
<TextBlock Text="Schülerbericht" Classes="dialogtitle"/>
|
||||
<TextBlock Text="{Binding StudentName}"/>
|
||||
</StackPanel>
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel Spacing="12" IsEnabled="{Binding !IsGenerating}">
|
||||
<TextBlock Text="Lerngruppe und Schuljahr" FontWeight="SemiBold"/>
|
||||
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
|
||||
DisplayMemberBinding="{Binding Display}" HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Text="Vorlage" FontWeight="SemiBold"/>
|
||||
<ComboBox ItemsSource="{Binding Templates}" SelectedItem="{Binding SelectedTemplate}"
|
||||
DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch"/>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Von (TT.MM.JJJJ)"/>
|
||||
<TextBox Text="{Binding From}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="Bis (TT.MM.JJJJ)"/>
|
||||
<TextBox Text="{Binding To}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<CheckBox Content="Notenzwischenstand" IsChecked="{Binding IncludeInterim}"/>
|
||||
<ComboBox ItemsSource="{Binding Interims}" SelectedItem="{Binding SelectedInterim}"
|
||||
DisplayMemberBinding="{Binding Label}" HorizontalAlignment="Stretch"
|
||||
PlaceholderText="Gespeicherten Zwischenstand auswählen" IsVisible="{Binding IncludeInterim}"/>
|
||||
<CheckBox Content="Mitarbeitsgraph mit gewichteter Mitarbeit und Einzelaspekten" IsChecked="{Binding IncludeParticipation}"/>
|
||||
<CheckBox Content="Kompetenzbewertungen" IsChecked="{Binding IncludeCompetencies}"/>
|
||||
<CheckBox Content="Hausaufgabenkontrollen" IsChecked="{Binding IncludeHomework}"/>
|
||||
<CheckBox Content="Klassenbucheinträge und Unterrichtsnotizen" IsChecked="{Binding IncludeDocumentation}"/>
|
||||
<CheckBox Content="Vertrauliche Dokumentation einschließen" IsChecked="{Binding IncludeConfidential}"
|
||||
IsVisible="{Binding IncludeDocumentation}"/>
|
||||
<TextBlock Text="Dokumentation wird auf die gewählte Lerngruppe begrenzt. Entwürfe werden ausgelassen."
|
||||
TextWrapping="Wrap" FontSize="12" Opacity="0.65"/>
|
||||
<TextBlock Text="Ergänzender Text" FontWeight="SemiBold"/>
|
||||
<TextBox Text="{Binding Comment}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="90"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<StackPanel Grid.Row="2" Spacing="10" Margin="0,12,0,0">
|
||||
<ProgressBar IsVisible="{Binding IsGenerating}" IsIndeterminate="True" Height="6"/>
|
||||
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap"/>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<Button Content="Schließen" Click="OnClose" IsEnabled="{Binding !IsGenerating}" HorizontalAlignment="Stretch"/>
|
||||
<Button Grid.Column="2" Content="PDF erzeugen…" Click="OnGenerate" IsEnabled="{Binding CanGenerate}" HorizontalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,28 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Students;
|
||||
|
||||
public partial class StudentReportDialog : Window
|
||||
{
|
||||
public StudentReportDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
Closing += (_, e) => e.Cancel = DataContext is StudentReportDialogViewModel { IsGenerating: true };
|
||||
}
|
||||
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||
private async void OnGenerate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not StudentReportDialogViewModel vm) return;
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Schülerbericht speichern", SuggestedFileName = vm.SuggestedFileName,
|
||||
DefaultExtension = "pdf", FileTypeChoices = [new FilePickerFileType("PDF") { Patterns = ["*.pdf"] }],
|
||||
});
|
||||
if (file is null) return;
|
||||
if (await vm.GenerateAsync(file.Path.LocalPath))
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(file.Path.LocalPath) { UseShellExecute = true });
|
||||
}
|
||||
}
|
||||
@@ -154,9 +154,12 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding HasAvailableDocumentation}">
|
||||
<TextBlock Text="Bestehende Dokumentation dieses Schülers verknüpfen" FontSize="12"
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Vorhandenen Akteneintrag verknüpfen" FontSize="12"
|
||||
FontWeight="SemiBold" Opacity="0.7"/>
|
||||
<TextBlock Text="Keine weiteren passenden Akteneinträge vorhanden. Neue Einträge aus der Schülerakte werden beim erneuten Öffnen des Tabs geladen."
|
||||
FontSize="11" Opacity="0.6" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasAvailableDocumentation}"/>
|
||||
<ItemsControl ItemsSource="{Binding AvailableDocumentation}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="svm:DocumentationItem">
|
||||
|
||||
@@ -6,6 +6,23 @@ namespace LehrerApp.TemplateDesigner.Tests;
|
||||
|
||||
public sealed class ExamCoverDesignerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Schuelerbericht_EinrichtenUndSonderinhalte_RendernMitBeispieldaten()
|
||||
{
|
||||
var vm = new DesignerViewModel();
|
||||
vm.Reset();
|
||||
vm.SetUpStudentReport();
|
||||
vm.SetUpStudentReport();
|
||||
vm.LayoutSource = StudentReportTemplate.Layout;
|
||||
Assert.Equal(TemplateReportTypes.StudentReport, vm.BuildLoaded().Manifest.Metadata[TemplateMetadataKeys.ReportType]);
|
||||
Assert.Equal(StudentReportTemplate.Create().Manifest.Placeholders.Count, vm.Placeholders.Count);
|
||||
using var pdf = UglyToad.PdfPig.PdfDocument.Open(vm.RenderCurrentPdf());
|
||||
var text = string.Concat(pdf.GetPages().Select(p => p.Text)).Replace(" ", "");
|
||||
Assert.Contains("GewichteteMitarbeit", text);
|
||||
Assert.Contains("Hausaufgaben", text);
|
||||
Assert.Contains("VorläufigeZeugnisnote", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetUpExamCover_SetztVorlagentypUndErgaenztFehlendePlatzhalter()
|
||||
{
|
||||
|
||||
@@ -180,10 +180,31 @@ public partial class DesignerViewModel : ObservableObject
|
||||
/// später in der Hauptapp.</summary>
|
||||
private static ITemplateDataProvider FitSampleData(LoadedTemplate template, ITemplateDataProvider provider)
|
||||
{
|
||||
var values = provider.GetValues();
|
||||
if (values.GetValueOrDefault(ExamPointsTableDrawing.PlaceholderName) is not DrawingValue) return provider;
|
||||
return new DesignerDataProvider(new Dictionary<string, PlaceholderValue>(values, StringComparer.Ordinal)
|
||||
{ [ExamPointsTableDrawing.PlaceholderName] = ExamCoverPlaceholders.SampleTable(template) });
|
||||
var values = new Dictionary<string, PlaceholderValue>(provider.GetValues(), StringComparer.Ordinal);
|
||||
if (values.GetValueOrDefault(ExamPointsTableDrawing.PlaceholderName) is DrawingValue)
|
||||
values[ExamPointsTableDrawing.PlaceholderName] = ExamCoverPlaceholders.SampleTable(template);
|
||||
foreach (var name in StudentReportTemplate.Drawings.Append(ReportTextDrawing.RepeatInfo))
|
||||
if (values.GetValueOrDefault(name) is DrawingValue)
|
||||
values[name] = StudentReportTemplate.SampleDrawing(name, DrawingBoxLocator.Find(template, name));
|
||||
return new DesignerDataProvider(values);
|
||||
}
|
||||
|
||||
public void SetUpStudentReport()
|
||||
{
|
||||
var reportType = MetadataItems.FirstOrDefault(x => x.Name.Trim().Equals(TemplateMetadataKeys.ReportType, StringComparison.OrdinalIgnoreCase));
|
||||
if (reportType is null) MetadataItems.Add(new(TemplateMetadataKeys.ReportType, TemplateReportTypes.StudentReport));
|
||||
else reportType.Value = TemplateReportTypes.StudentReport;
|
||||
var conflicts = new List<string>();
|
||||
foreach (var known in StudentReportTemplate.Create().Manifest.Placeholders)
|
||||
{
|
||||
var existing = Placeholders.FirstOrDefault(p => p.Name.Trim() == known.Name);
|
||||
if (existing is null) Placeholders.Add(new(known.Name, known.Type, false,
|
||||
known.Name == "Report.Period" ? "01.08.2025 - 31.01.2026" : known.Name == "Student.Name" ? "Erika Beispiel" : known.Name == "Group.Name" ? "9 Ch G" : ""));
|
||||
else if (existing.Type != known.Type) conflicts.Add(known.Name);
|
||||
}
|
||||
CanExport = false;
|
||||
SetStatus(conflicts.Count == 0 ? "Als Schülerbericht eingerichtet. Bausteine stehen unter LehrerApp-Sonderinhalte."
|
||||
: "Abweichender Platzhaltertyp: " + string.Join(", ", conflicts), conflicts.Count > 0);
|
||||
}
|
||||
|
||||
public bool IsExamCover => MetadataItems.Any(x =>
|
||||
@@ -796,6 +817,24 @@ public static class SpecialContentCatalog
|
||||
new("Fehltage nach Datum", "Student.AbsenceDays",
|
||||
"Chronologische Liste mit Datum, Fehlzeit oder ganzem Fehltag sowie Entschuldigungsstatus.",
|
||||
"FLOWDRAWBOX", "170", "140"),
|
||||
new("Wiederholungsarbeit", ReportTextDrawing.RepeatInfo,
|
||||
"Erste Arbeit, bisherige Note und Bewertungsregel; wird bei normalen Arbeiten leer gelassen.",
|
||||
"FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true),
|
||||
new("Notenzwischenstand", ReportTextDrawing.InterimGrades,
|
||||
"Mündlich, schriftlich und vorläufige Zeugnisnote aus einem beim Druck gewählten gespeicherten Durchgang.",
|
||||
"FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true),
|
||||
new("Mitarbeitsgraph (Schülerbericht)", StudentReportTemplate.Participation,
|
||||
"Gewichtete Mitarbeit und Einzelaspekte im gewählten Zeitraum auf der gemeinsamen -2 bis +2-Achse.",
|
||||
"FLOWDRAWBOX", "170", "230", "fit=content", InContentFlow: true),
|
||||
new("Kompetenzen (Schülerbericht)", StudentReportTemplate.Competencies,
|
||||
"Kompetenzbewertungen aus Klausuren und Mitarbeit im gewählten Zeitraum.",
|
||||
"FLOWDRAWBOX", "170", "230", "fit=content", InContentFlow: true),
|
||||
new("Hausaufgaben (Schülerbericht)", StudentReportTemplate.Homework,
|
||||
"Hausaufgabenkontrollen mit Datum und aktuellem Nachreichstatus.",
|
||||
"FLOWDRAWBOX", "170", "230", "fit=content", InContentFlow: true),
|
||||
new("Klassenbucheinträge (Schülerbericht)", StudentReportTemplate.Documentation,
|
||||
"Freigegebene Dokumentation und Unterrichtsnotizen für einen Schüler und eine Lerngruppe.",
|
||||
"FLOWDRAWBOX", "170", "230", "fit=content", InContentFlow: true),
|
||||
new("Punktetabelle (Klausur-Deckblatt)", ExamPointsTableDrawing.PlaceholderName,
|
||||
"Aufgabe / Max. Punktzahl / Punktzahl mit festen Spaltenbreiten; bei vielen Aufgaben folgt ein weiterer Block, und die Elemente darunter im Content-Flow wandern mit. Die Breite bestimmt, wie viele Aufgaben nebeneinander passen (165 = sechs).",
|
||||
"FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true),
|
||||
@@ -824,7 +863,12 @@ public static class SpecialContentCatalog
|
||||
new DrawString(42, 27, "Ganzer Fehltag", 7, "#374151"),
|
||||
new DrawString(116, 27, "Entschuldigt", 7, "#2E7D32"),
|
||||
], 38),
|
||||
ReportTextDrawing.RepeatInfo => ReportTextDrawing.Build("Wiederholungsarbeit",
|
||||
["Erste Arbeit: Atombau vom 18.12.2025", "Note der ersten Arbeit: 4", "Für die Bewertung zählt die bessere Note aus beiden Arbeiten."]),
|
||||
ReportTextDrawing.InterimGrades => ReportTextDrawing.Build("Vorläufiger Notenzwischenstand",
|
||||
["Stichtag: 15.01.2026 · Sprechtag", "Mündlich: 2", "Schriftlich: 3", "Vorläufige Zeugnisnote: 2-3"]),
|
||||
ExamPointsTableDrawing.PlaceholderName => ExamPointsTableDrawing.Build(ExamCoverPlaceholders.SampleColumns, 165),
|
||||
_ when StudentReportTemplate.Drawings.Contains(placeholderName) => StudentReportTemplate.SampleDrawing(placeholderName),
|
||||
_ => DesignerPlaceholder.GenericSampleDrawing(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ public sealed class LayoutOverlayEditor : Control
|
||||
TableElement => "TABLE", ChartElement => "CHART", _ => "?" };
|
||||
var (width, height, resizable) = element switch
|
||||
{
|
||||
TextElement text => (Math.Min(70d, Math.Max(5, PageWidth - text.X)),
|
||||
TextElement text => (TextLineWidth(text),
|
||||
Math.Max(3, PointsToUnit(ParseSize(text.Attributes) * 1.8)), false),
|
||||
ImageElement image => (image.Width * ImageScale(image), image.Height * ImageScale(image), true),
|
||||
_ => ((double)element.Width, element.Height, true),
|
||||
@@ -254,6 +254,18 @@ public sealed class LayoutOverlayEditor : Control
|
||||
private static float ParseSize(IReadOnlyDictionary<string, string> attributes) =>
|
||||
attributes.TryGetValue("size", out var raw) && float.TryParse(raw,
|
||||
System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var size) ? size : 11;
|
||||
// Mit w= oder align=center/right entspricht der Rahmen der Zeilenbreite, in der der Renderer ausrichtet.
|
||||
private double TextLineWidth(TextElement text)
|
||||
{
|
||||
var remaining = Math.Max(5, PageWidth - text.X);
|
||||
if (text.Attributes.TryGetValue("w", out var raw) && float.TryParse(raw,
|
||||
System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var width)
|
||||
&& width > 0)
|
||||
return width;
|
||||
return text.Attributes.TryGetValue("align", out var align)
|
||||
&& (align.Equals("center", StringComparison.OrdinalIgnoreCase) || align.Equals("right", StringComparison.OrdinalIgnoreCase))
|
||||
? remaining : Math.Min(70d, remaining);
|
||||
}
|
||||
private static float ImageScale(ImageElement image) => image.Attributes.TryGetValue("scale", out var raw)
|
||||
? LayoutParser.Percentage(raw) : 1;
|
||||
private double PointsToUnit(double points) => PageUnit.ToLowerInvariant() switch
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<Button Content="Markierten Metadateneintrag entfernen" HorizontalAlignment="Left" Click="OnRemoveMetadata"/>
|
||||
<Button Content="Als Schülerbericht einrichten" HorizontalAlignment="Left" Click="OnSetUpStudentReport"/>
|
||||
<Button Content="Als Klausur-Deckblatt einrichten" HorizontalAlignment="Left" Click="OnSetUpExamCover"
|
||||
ToolTip.Tip="Setzt report-type=exam-cover und legt alle Platzhalter an, die die LehrerApp für Deckblätter befüllt (Exam.*, Result.*, …) — mit Beispielwerten für die Vorschau."/>
|
||||
</StackPanel>
|
||||
|
||||
@@ -38,6 +38,7 @@ public partial class MainWindow : Window
|
||||
if (_viewModel.SelectedMetadata is not { } selected) return;
|
||||
_viewModel.MetadataItems.Remove(selected); _viewModel.SelectedMetadata = null; _viewModel.CanExport = false;
|
||||
}
|
||||
private void OnSetUpStudentReport(object? sender, RoutedEventArgs e) => _viewModel.SetUpStudentReport();
|
||||
private void OnSetUpExamCover(object? sender, RoutedEventArgs e) => _viewModel.SetUpExamCover();
|
||||
private void OnRemovePlaceholder(object? sender, RoutedEventArgs e) => _viewModel.RemoveSelectedPlaceholder();
|
||||
private void OnBoldSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[b]", "[/b]");
|
||||
|
||||
@@ -61,14 +61,17 @@ public sealed class ExamCoverTemplatingTests : IDisposable
|
||||
var tall = new DrawingValue(
|
||||
[new DrawStringEx(0, 290, 12, 170, "LANG", DrawingTextAlignment.AlignLeft, 10)], 300);
|
||||
|
||||
var progress = new RecordingProgress();
|
||||
|
||||
var result = new QuestTemplateRenderer().RenderBatchToPdf(template,
|
||||
[
|
||||
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Tabelle"] = ShortDrawing }),
|
||||
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Tabelle"] = tall }),
|
||||
]);
|
||||
], progress);
|
||||
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(result.Pdf);
|
||||
Assert.Equal([1, 2], result.PageCounts);
|
||||
Assert.Equal([1, 2], progress.Reported);
|
||||
Assert.Equal(3, document.NumberOfPages);
|
||||
Assert.Contains("TABELLE", document.GetPage(1).Text);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Templating.Tests;
|
||||
|
||||
public sealed class ReportDrawingTests
|
||||
{
|
||||
[Fact]
|
||||
public void LangeTexte_WerdenNurZwischenZeilenAufSeitenVerteilt()
|
||||
{
|
||||
var drawing = ReportTextDrawing.Build("Überschrift", [new string('W', 300)], 55, 1, 17);
|
||||
var lines = drawing.Commands.OfType<DrawStringEx>().ToList();
|
||||
Assert.True(drawing.ContentHeight > 17);
|
||||
Assert.All(lines, line => Assert.True(line.Y % 17 + line.Height <= 17.001f));
|
||||
Assert.Equal(new string('W', 300), string.Concat(lines.Skip(1).Select(line => line.Text)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FesteBox_MeldetUeberlaufStattAbzuschneiden()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => ReportTextDrawing.Build("Titel", [new string('W', 300)], 55, 1, 17, true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TexteUndGraphen_BeachtenDieMassEinheit()
|
||||
{
|
||||
var scale = 72f / 25.4f;
|
||||
var mm = ReportTextDrawing.Build("Titel", ["Eine Beobachtung zum Lernfortschritt."], 170);
|
||||
var pt = ReportTextDrawing.Build("Titel", ["Eine Beobachtung zum Lernfortschritt."], 170 * scale, scale, 240 * scale);
|
||||
Assert.Equal(mm.ContentHeight * scale, pt.ContentHeight, 2);
|
||||
var from = new DateOnly(2026, 10, 1);
|
||||
ReportChartSeries[] series = [new("Qualität", "#123456", [new(from, 1), new(from.AddDays(1), -1)])];
|
||||
var chartMm = ParticipationReportDrawing.Build(series, from, from.AddDays(1));
|
||||
var chartPt = ParticipationReportDrawing.Build(series, from, from.AddDays(1), 170 * scale, scale);
|
||||
Assert.Equal(chartMm.ContentHeight * scale, chartPt.ContentHeight, 2);
|
||||
}
|
||||
}
|
||||
@@ -394,3 +394,11 @@ internal sealed class DictionaryProvider(IReadOnlyDictionary<string, Placeholder
|
||||
{
|
||||
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
|
||||
}
|
||||
|
||||
/// <summary>Zeichnet Meldungen synchron auf — <see cref="Progress{T}"/> würde sie ohne
|
||||
/// SynchronizationContext erst später auf dem Threadpool zustellen.</summary>
|
||||
internal sealed class RecordingProgress : IProgress<int>
|
||||
{
|
||||
public List<int> Reported { get; } = [];
|
||||
public void Report(int value) => Reported.Add(value);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ public static class ExamPointsTableDrawing
|
||||
var column = block[index];
|
||||
var x = origin + (LabelWidth + index * ColumnWidth) * k;
|
||||
Cell(commands, x, y, ColumnWidth * k, HeaderHeight * k,
|
||||
column.IsBonus ? "" : $"Nr. {column.Number}", DrawingTextAlignment.AlignCenter, false, k);
|
||||
column.IsBonus ? "Bonus" : $"Nr. {column.Number}", DrawingTextAlignment.AlignCenter, false, k);
|
||||
Cell(commands, x, maximumY, ColumnWidth * k, MaximumHeight * k,
|
||||
(column.IsBonus ? "+" : "") + Format(column.MaxPoints), DrawingTextAlignment.AlignCenter, false, k);
|
||||
Cell(commands, x, pointsY, ColumnWidth * k, PointsHeight * k,
|
||||
@@ -117,6 +117,8 @@ public static class ExamCoverPlaceholders
|
||||
new("Result.Points", PlaceholderType.Text, "52,5", "Erreichte Punkte, als Blanko eine Linie"),
|
||||
new("Result.Percent", PlaceholderType.Text, "85 %", "Erreichter Anteil, als Blanko leer"),
|
||||
new("Result.Grade", PlaceholderType.Text, "2", "Note, als Blanko eine Linie"),
|
||||
new(ReportTextDrawing.RepeatInfo, PlaceholderType.Drawing, "", "Informationen zur ersten Arbeit und Bewertungsregel der Wiederholung"),
|
||||
new(ReportTextDrawing.InterimGrades, PlaceholderType.Drawing, "", "Gespeicherter Zwischenstand: mündlich, schriftlich, vorläufige Zeugnisnote"),
|
||||
new(ExamPointsTableDrawing.PlaceholderName, PlaceholderType.Drawing, "",
|
||||
"Punktetabelle (Aufgabe / Max. Punktzahl / Punktzahl)"),
|
||||
];
|
||||
|
||||
@@ -192,7 +192,10 @@ public sealed record BatchRenderResult(byte[] Pdf, IReadOnlyList<int> PageCounts
|
||||
public interface ITemplateBatchRenderer
|
||||
{
|
||||
/// <summary>Rendert dieselbe Vorlage je Datensatz und fügt alles zu einem PDF zusammen.</summary>
|
||||
BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList<ITemplateDataProvider> data);
|
||||
/// <param name="progress">Bekommt nach jedem gesetzten Datensatz deren bisherige Anzahl. Nach
|
||||
/// dem letzten folgt noch das Zusammenfügen, das sich nicht weiter unterteilen lässt.</param>
|
||||
BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList<ITemplateDataProvider> data,
|
||||
IProgress<int>? progress = null);
|
||||
}
|
||||
|
||||
public interface ITemplatePreviewRenderer
|
||||
|
||||
@@ -14,17 +14,23 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
public byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data) =>
|
||||
BuildDocument(template, ValidateData(template, data)).GeneratePdf();
|
||||
|
||||
public BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList<ITemplateDataProvider> data)
|
||||
public BatchRenderResult RenderBatchToPdf(LoadedTemplate template, IReadOnlyList<ITemplateDataProvider> data,
|
||||
IProgress<int>? progress = null)
|
||||
{
|
||||
if (data.Count == 0) throw new ArgumentException("Mindestens ein Datensatz ist erforderlich.", nameof(data));
|
||||
var documents = data.Select(x => BuildDocument(template, ValidateData(template, x))).ToList();
|
||||
// Seitenzahl je Datensatz getrennt ermitteln: das zusammengeführte Dokument verrät nicht
|
||||
// mehr, welcher Datensatz unerwartet auf eine Folgeseite übergelaufen ist.
|
||||
var pageCounts = documents.Select(x => PdfPageCounter.Count(x.GeneratePdf())).ToList();
|
||||
var single = new List<byte[]>(documents.Count);
|
||||
foreach (var document in documents)
|
||||
{
|
||||
single.Add(document.GeneratePdf());
|
||||
progress?.Report(single.Count);
|
||||
}
|
||||
var pdf = documents.Count == 1
|
||||
? documents[0].GeneratePdf()
|
||||
? single[0]
|
||||
: Document.Merge(documents).UseOriginalPageNumbers().GeneratePdf();
|
||||
return new(pdf, pageCounts);
|
||||
return new(pdf, single.Select(PdfPageCounter.Count).ToList());
|
||||
}
|
||||
|
||||
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
|
||||
@@ -220,6 +226,18 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
canvas.Layers(layers =>
|
||||
{
|
||||
layers.PrimaryLayer().Background(Colors.White);
|
||||
// Ohne eigene Folgeseitenvorlage ist derselbe Hintergrund auf jeder Seite
|
||||
// vorgesehen. Zwei zustandsbehaftete ShowOnce/SkipOnce-Layer für dieselben
|
||||
// Elemente können sich beim Paginieren gegenseitig verdrängen.
|
||||
if (ReferenceEquals(first, continuation))
|
||||
{
|
||||
foreach (var element in first.Elements)
|
||||
{
|
||||
var current = element;
|
||||
layers.Layer().Repeat().Element(root => RenderElement(root, current, template, values));
|
||||
}
|
||||
return;
|
||||
}
|
||||
foreach (var element in first.Elements)
|
||||
{
|
||||
var current = element;
|
||||
@@ -333,11 +351,11 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
values, template.Manifest, text.Attributes);
|
||||
break;
|
||||
case TextBoxElement box:
|
||||
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
|
||||
RenderResolvedText(Position(root, box, unit).ShrinkVertical(), box.Content, box.Placeholder, box.Format,
|
||||
values, template.Manifest, box.Attributes);
|
||||
break;
|
||||
case FlowBoxElement box:
|
||||
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
|
||||
RenderResolvedText(Position(root, box, unit).ShrinkVertical(), box.Content, box.Placeholder, box.Format,
|
||||
values, template.Manifest, box.Attributes);
|
||||
break;
|
||||
case DrawBoxElement drawing:
|
||||
|
||||
@@ -111,7 +111,9 @@ deterministisch mit einem weißen Asset maskiert und anschließend als `TEXT` od
|
||||
eingefügt. Der Nutzer prüft alle Vorschläge im Importdialog und muss die Übernahme ausdrücklich
|
||||
bestätigen. Das Paket wird dabei noch nicht gespeichert.
|
||||
|
||||
Die serverseitige Klassifikation liegt in `ai-backend/pdf-template.php` und verwendet denselben
|
||||
Die serverseitige Klassifikation liegt in
|
||||
[`LehrerBackend/pdf-template.php`](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/pdf-template.php)
|
||||
und verwendet denselben
|
||||
Login-, Bearer-Token-, Guthaben- und Abrechnungsmechanismus wie die übrigen KI-Funktionen. Das
|
||||
Passwort wird vom eigenständigen Designer nicht gespeichert. Tabellen-/Chart-Erkennung und die
|
||||
automatische Rekonstruktion mehrseitiger Vorlagen sind bewusst nicht Teil von v1.
|
||||
@@ -314,3 +316,39 @@ Höhe ins Elementformular. Im Bearbeitungsmodus lassen sich vorhandene Elemente
|
||||
außer einzeiligem `TEXT` - am rechten unteren Anfasser skalieren. Rasterfang und Pfeiltasten sind
|
||||
für Feinkorrekturen verfügbar. Änderungen werden in die ursprüngliche DSL-Zeile zurückgeschrieben,
|
||||
wobei Inhalte, Platzhalter, Formatangaben und Attribute erhalten bleiben.
|
||||
|
||||
|
||||
### Weitere Deckblatt- und Schülerbericht-Bausteine
|
||||
|
||||
Die Sonderinhalte im TemplateDesigner umfassen zusätzlich:
|
||||
|
||||
| Platzhalter (Drawing) | Inhalt |
|
||||
| --- | --- |
|
||||
| `Exam.RepeatInfo` | Erste Arbeit, bisherige Note und Regel der Wiederholung; bei normalen Arbeiten leer |
|
||||
| `Student.InterimGrades` | Gespeicherter Zwischenstand mit Stichtag/Anlass, mündlich, schriftlich und vorläufiger Zeugnisnote |
|
||||
| `Student.ParticipationChart` | Gewichtete Mitarbeit und Einzelaspekte auf der gemeinsamen -2 bis +2-Achse |
|
||||
| `Student.Competencies` | Kompetenzbewertungen aus Klausuren und Mitarbeit |
|
||||
| `Student.Homework` | Hausaufgabenkontrollen mit Datum und aktuellem Nachreichstatus |
|
||||
| `Student.Documentation` | Dokumentation und Unterrichtsnotizen der gewählten Lerngruppe |
|
||||
|
||||
`Exam.RepeatInfo` und `Student.InterimGrades` sind in Klausur-Deckblättern verfügbar.
|
||||
Ein Zwischenstand wird im Druckdialog über Datum und Anlass gewählt; fehlt ein ausgewählter
|
||||
Schüler in diesem Durchgang, wird der Druck mit einem Hinweis verhindert.
|
||||
|
||||
Für Schülerberichte im Schülerdetail gibt es eine eingebaute Standardvorlage und importierbare
|
||||
Pakete mit `report-type=student-report` (in einer der beiden vorhandenen Vorlagenbibliotheken).
|
||||
Im Designer setzt „Als Schülerbericht einrichten“ den Typ und ergänzt die benötigten Platzhalter;
|
||||
die Sonderinhalte werden danach nach Bedarf in den Content-Flow eingefügt. Es werden außerdem
|
||||
`Student.Name`, `Group.Name`, `Report.Period` (Text) und `Report.Comment` (Multiline) befüllt.
|
||||
Es gibt keine erforderliche Kontaktadresse: der Bericht ist für den Austausch mit Kollegen gedacht.
|
||||
|
||||
Für lange Inhalte `FLOWDRAWBOX … fit=content` verwenden. Ihre Höhe muss einschließlich `gap`
|
||||
in den Flow-Slot passen; die Standardvorlage nutzt 230 mm Inhaltshöhe in einem 240-mm-Slot.
|
||||
Feste `DRAWBOX`-Textblöcke melden Platzmangel. Der Mitarbeitsgraph braucht mindestens 50 mm
|
||||
Breite und ausreichend Höhe für seine Legende. Die Textbausteine beachten die Maßeinheit der Vorlage.
|
||||
|
||||
Der Bericht filtert auf Schüler, Gruppe und Zeitraum. Gelöschte Dokumentation und Entwürfe werden
|
||||
nicht übernommen; vertrauliche Dokumentation ist im Dialog zuschaltbar. Hausaufgabenstatus und
|
||||
Dokumentation entsprechen dem aktuellen gespeicherten Zustand. Notenzwischenstände werden aus
|
||||
gespeicherten Durchgängen übernommen und nicht beim Druck neu berechnet. KI-Texterzeugung ist
|
||||
noch nicht angebunden; ergänzende Texte lassen sich im Berichtsdialog eingeben.
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
/// <summary>Lesbare, umbrechende Textblöcke für Berichtsbausteine. Jede Zeile ist ein eigener
|
||||
/// Zeichenbefehl, damit FLOWDRAWBOX auch lange Berichte an Zeilengrenzen aufteilen kann.</summary>
|
||||
public static class ReportTextDrawing
|
||||
{
|
||||
public const string RepeatInfo = "Exam.RepeatInfo";
|
||||
public const string InterimGrades = "Student.InterimGrades";
|
||||
|
||||
public static DrawingValue Build(string title, IEnumerable<string> paragraphs,
|
||||
float width = 170, float scale = 1, float pageHeight = 240, bool fixedBox = false)
|
||||
{
|
||||
if (!float.IsFinite(width) || !float.IsFinite(scale) || !float.IsFinite(pageHeight)
|
||||
|| scale <= 0 || width < 20 * scale || pageHeight < 12 * scale)
|
||||
throw new InvalidDataException("Der Berichtsbaustein benötigt mindestens 20 × 12 mm Platz.");
|
||||
var commands = new List<DrawingCommand>();
|
||||
var lineHeight = 5f * scale;
|
||||
var y = 0f;
|
||||
// Konservative Zeichenbreiten in Gevierten der Standardschrift. Breite Buchstaben
|
||||
// bekommen mehr Raum; auch einzelne überlange Wörter werden umgebrochen.
|
||||
var available = (width - 2 * scale) / (10 * 25.4f / 72 * scale);
|
||||
static float Advance(char c) => char.IsWhiteSpace(c) ? .4f
|
||||
: c is 'm' or 'w' or 'M' or 'W' ? 1.1f
|
||||
: c > 255 ? 1.1f : char.IsUpper(c) ? 1f : .7f;
|
||||
void Line(string text, bool bold)
|
||||
{
|
||||
var offset = y % pageHeight;
|
||||
if (offset + lineHeight > pageHeight + .001f) y += pageHeight - offset;
|
||||
if (fixedBox && y + lineHeight > pageHeight + .001f)
|
||||
throw new InvalidDataException("Der Berichtsbaustein passt nicht in die feste DRAWBOX. Bitte FLOWDRAWBOX verwenden oder die Box vergrößern.");
|
||||
commands.Add(new DrawStringEx(scale, y, lineHeight, width - 2 * scale, text,
|
||||
DrawingTextAlignment.AlignLeft, 10, Bold: bold));
|
||||
y += lineHeight;
|
||||
}
|
||||
foreach (var paragraph in new[] { title }.Concat(paragraphs))
|
||||
{
|
||||
foreach (var original in paragraph.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
var remaining = original.Trim();
|
||||
if (remaining.Length == 0) { Line("", false); continue; }
|
||||
while (remaining.Length > 0)
|
||||
{
|
||||
var length = 0;
|
||||
var used = 0f;
|
||||
while (length < remaining.Length && used + Advance(remaining[length]) <= available)
|
||||
used += Advance(remaining[length++]);
|
||||
length = Math.Max(1, length);
|
||||
var cut = length;
|
||||
if (length < remaining.Length)
|
||||
{
|
||||
var space = remaining.LastIndexOf(' ', length - 1, length);
|
||||
if (space > 0) cut = space;
|
||||
}
|
||||
Line(remaining[..cut], paragraph == title);
|
||||
remaining = remaining[cut..].TrimStart();
|
||||
}
|
||||
}
|
||||
}
|
||||
return new DrawingValue(commands, y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public static class StudentReportTemplate
|
||||
{
|
||||
public const string Participation = "Student.ParticipationChart";
|
||||
public const string Competencies = "Student.Competencies";
|
||||
public const string Homework = "Student.Homework";
|
||||
public const string Documentation = "Student.Documentation";
|
||||
public static IReadOnlyList<string> Drawings { get; } =
|
||||
[ReportTextDrawing.InterimGrades, Participation, Competencies, Homework, Documentation];
|
||||
|
||||
public const string Layout = """
|
||||
PAGE 210 297 mm
|
||||
#pragma format-version 3
|
||||
#pragma page-template first
|
||||
TEXT 20 12 "Schülerbericht: $Student.Name" size=15
|
||||
TEXT 20 22 "Lerngruppe: $Group.Name · $Report.Period" size=10
|
||||
#pragma flow-slot body x=20 y=34 w=170 h=240
|
||||
#pragma end-page-template
|
||||
#pragma content-flow body
|
||||
FLOWDRAWBOX $Student.InterimGrades w=170 h=230 fit=content gap=5
|
||||
FLOWDRAWBOX $Student.ParticipationChart w=170 h=230 fit=content gap=5
|
||||
FLOWDRAWBOX $Student.Competencies w=170 h=230 fit=content gap=5
|
||||
FLOWDRAWBOX $Student.Homework w=170 h=230 fit=content gap=5
|
||||
FLOWDRAWBOX $Student.Documentation w=170 h=230 fit=content gap=5
|
||||
FLOWBOX $Report.Comment w=170 size=10
|
||||
#pragma end-content-flow
|
||||
""";
|
||||
|
||||
public static DrawingValue SampleDrawing(string name, DeclaredDrawingBox? declared = null)
|
||||
{
|
||||
var box = declared ?? new DeclaredDrawingBox(170, 240, 1, false);
|
||||
var from = new DateOnly(2026, 1, 1);
|
||||
if (name == Participation)
|
||||
return ParticipationReportDrawing.Build([new("Gewichtete Mitarbeit", "#6C5CE7",
|
||||
[new(from, -.5), new(from.AddDays(7), .5), new(from.AddDays(14), 1.2)])], from, from.AddDays(20), box.Width, box.MillimeterScale);
|
||||
var (title, text) = name switch
|
||||
{
|
||||
ReportTextDrawing.RepeatInfo => ("Wiederholungsarbeit", new[] { "Erste Arbeit: Atombau vom 18.12.2025", "Note der ersten Arbeit: 4", "Für die Bewertung zählt die bessere Note aus beiden Arbeiten." }),
|
||||
ReportTextDrawing.InterimGrades => ("Vorläufiger Notenzwischenstand", new[] { "Stichtag: 15.01.2026 · Sprechtag", "Mündlich: 2", "Schriftlich: 3", "Vorläufige Zeugnisnote: 2-3" }),
|
||||
Competencies => ("Kompetenzen", new[] { "CH1 Aufbau der Atome beschreiben: 75 % (4 Bewertungen)" }),
|
||||
Homework => ("Hausaufgaben", new[] { "12.01.2026: Hausaufgabe gemacht", "14.01.2026: Hausaufgabe nachgereicht" }),
|
||||
_ => ("Klassenbucheinträge und Unterrichtsnotizen", new[] { "15.01.2026 · Gespräch: Lernentwicklung", "Erika arbeitet inzwischen regelmäßig mit." }),
|
||||
};
|
||||
return ReportTextDrawing.Build(title, text, box.Width, box.MillimeterScale, box.Height, box.IsFixed);
|
||||
}
|
||||
|
||||
public static LoadedTemplate Create() => new(new TemplateManifest
|
||||
{
|
||||
Id = "student-report-standard", Name = "Schülerbericht Standard",
|
||||
Metadata = new() { [TemplateMetadataKeys.ReportType] = TemplateReportTypes.StudentReport },
|
||||
Placeholders = new[]
|
||||
{
|
||||
new PlaceholderDefinition("Student.Name", PlaceholderType.Text),
|
||||
new PlaceholderDefinition("Group.Name", PlaceholderType.Text),
|
||||
new PlaceholderDefinition("Report.Period", PlaceholderType.Text),
|
||||
new PlaceholderDefinition("Report.Comment", PlaceholderType.Multiline),
|
||||
}.Concat(Drawings.Select(n => new PlaceholderDefinition(n, PlaceholderType.Drawing))).ToList(),
|
||||
}, new LayoutParser().Parse(Layout), new Dictionary<string, byte[]>());
|
||||
}
|
||||
|
||||
public sealed record ReportChartPoint(DateOnly Date, double Value);
|
||||
public sealed record ReportChartSeries(string Label, string Color, IReadOnlyList<ReportChartPoint> Points);
|
||||
|
||||
public static class ParticipationReportDrawing
|
||||
{
|
||||
public static DrawingValue Build(IReadOnlyList<ReportChartSeries> series, DateOnly from, DateOnly to,
|
||||
float width = 170, float scale = 1)
|
||||
{
|
||||
if (from > to || width < 50 * scale || scale <= 0 || !float.IsFinite(width) || !float.IsFinite(scale))
|
||||
throw new InvalidDataException("Der Mitarbeitsgraph benötigt mindestens 50 mm Breite und einen gültigen Zeitraum.");
|
||||
if (series.All(s => s.Points.Count == 0))
|
||||
return ReportTextDrawing.Build("Mitarbeitsentwicklung", ["Keine bewerteten Sitzungen im gewählten Zeitraum."], width, scale);
|
||||
var commands = new List<DrawingCommand>();
|
||||
void Text(float x, float y, string text, string color = "#334155", bool bold = false) =>
|
||||
commands.Add(new DrawString(x * scale, y * scale, text, 9, color, Bold: bold));
|
||||
var right = width / scale - 4;
|
||||
const float left = 12, top = 14, height = 40;
|
||||
Text(1, 0, "Mitarbeitsentwicklung (-2 bis +2)", bold: true);
|
||||
for (var value = -2; value <= 2; value++)
|
||||
{
|
||||
var y = top + (2 - value) / 4f * height;
|
||||
Text(1, y - 2, value.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
commands.Add(new DrawLine(left * scale, y * scale, right * scale, y * scale, "#CBD5E1", .2f * scale));
|
||||
}
|
||||
var days = Math.Max(1, to.DayNumber - from.DayNumber);
|
||||
foreach (var item in series)
|
||||
{
|
||||
(float X, float Y)? previous = null;
|
||||
foreach (var point in item.Points.Where(p => p.Date >= from && p.Date <= to).OrderBy(p => p.Date))
|
||||
{
|
||||
var x = (left + (point.Date.DayNumber - from.DayNumber) / (float)days * (right - left)) * scale;
|
||||
var y = (top + (2 - (float)Math.Clamp(point.Value, -2, 2)) / 4 * height) * scale;
|
||||
if (previous is { } prev) commands.Add(new DrawLine(prev.X, prev.Y, x, y, item.Color, .5f * scale));
|
||||
commands.Add(new DrawCircle(x, y, .8f * scale, item.Color, .2f * scale, item.Color));
|
||||
previous = (x, y);
|
||||
}
|
||||
}
|
||||
Text(left, 57, from.ToString("dd.MM.yyyy"));
|
||||
Text(Math.Max(left, right - 26), 57, to.ToString("dd.MM.yyyy"));
|
||||
var legendY = 64f;
|
||||
foreach (var item in series)
|
||||
{
|
||||
// Jede Kurve bekommt eine eigene beschriftete Legendenzeile.
|
||||
commands.Add(new DrawLine(left * scale, (legendY + 2) * scale, (left + 6) * scale, (legendY + 2) * scale, item.Color, .6f * scale));
|
||||
var legend = ReportTextDrawing.Build("", [item.Label], (right - left - 10) * scale, scale);
|
||||
// Legendentexte konservativ umbrechen, auch bei individuell langen Aspektnamen.
|
||||
foreach (var line in legend.Commands.OfType<DrawStringEx>().Skip(1))
|
||||
commands.Add(line with { X = (left + 9) * scale, Y = line.Y - 5 * scale + legendY * scale, Color = item.Color });
|
||||
legendY += legend.ContentHeight / scale - 5;
|
||||
}
|
||||
return new DrawingValue(commands, (legendY + 2) * scale);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public static class TemplateReportTypes
|
||||
{
|
||||
public const string Letter = "letter";
|
||||
public const string ExamCover = "exam-cover";
|
||||
public const string StudentReport = "student-report";
|
||||
}
|
||||
|
||||
public static class TemplateMetadataText
|
||||
|
||||
@@ -65,6 +65,7 @@ public static class TemplatePackage
|
||||
public sealed record InstalledTemplate(string Id, string Name, string Description, string PackagePath,
|
||||
DateTime ImportedAtUtc, string ReportType = "")
|
||||
{
|
||||
public bool IsStudentReport => ReportType.Equals(TemplateReportTypes.StudentReport, StringComparison.OrdinalIgnoreCase);
|
||||
public bool IsExamCover => ReportType.Equals(TemplateReportTypes.ExamCover, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,41 @@ public class ExamCoverSheetServiceTests
|
||||
Assert.False(ExamParticipantService.Includes(exam, new GroupMembership { Niveau = Niveau.E }, false, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Includes_Foerderarbeit_BlendetSchuelerMitNoteDerErstenArbeitAus()
|
||||
{
|
||||
var original = new Exam();
|
||||
var support = new Exam { IsSupportWork = true, RepeatOfExamId = original.Id, Date = original.Date };
|
||||
var wrote = Guid.NewGuid();
|
||||
var supported = Guid.NewGuid();
|
||||
List<ExamResult> originalResults =
|
||||
[
|
||||
new() { ExamId = original.Id, StudentId = wrote, Grade = "3" },
|
||||
new() { ExamId = original.Id, StudentId = supported, Absent = true },
|
||||
];
|
||||
|
||||
var wroteOriginal = ExamParticipantService.WroteOriginal(support, _ => originalResults);
|
||||
|
||||
Assert.Equal([wrote], wroteOriginal);
|
||||
var flagged = new GroupMembership { IsSupportStudent = true };
|
||||
Assert.False(ExamParticipantService.Includes(support, flagged, false, wroteOriginal.Contains(wrote)));
|
||||
Assert.True(ExamParticipantService.Includes(support, flagged, false, wroteOriginal.Contains(supported)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Includes_Foerderarbeit_NurMarkierteFoerderschueler()
|
||||
{
|
||||
var support = new Exam { IsSupportWork = true, RepeatOfExamId = Guid.NewGuid() };
|
||||
|
||||
Assert.True(ExamParticipantService.Includes(support, new GroupMembership { IsSupportStudent = true }, false, false));
|
||||
// In differenzierten Gruppen gilt das Förderniveau als Markierung.
|
||||
Assert.True(ExamParticipantService.Includes(support, new GroupMembership { Niveau = Niveau.Foerder }, false, false));
|
||||
Assert.False(ExamParticipantService.Includes(support, new GroupMembership { Niveau = Niveau.G }, false, false));
|
||||
Assert.False(ExamParticipantService.Includes(support, new GroupMembership(), false, false));
|
||||
// Ein bereits vorhandenes Ergebnis bleibt sichtbar, auch ohne Markierung.
|
||||
Assert.True(ExamParticipantService.Includes(support, new GroupMembership(), true, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Includes_Nachschreibarbeit_OhneErgebnisNurWerDieErsteArbeitVersaeumtHat()
|
||||
{
|
||||
|
||||
@@ -97,6 +97,19 @@ public class ExamScoringServiceTests
|
||||
[(original, "4"), (makeUp, "1")], GradingSystem.Grades1To6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Foerderarbeit_ErsetztNurFehlendeNote()
|
||||
{
|
||||
var original = new Exam();
|
||||
var support = new Exam { RepeatOfExamId = original.Id, IsSupportWork = true };
|
||||
|
||||
// Förderschüler hat die erste Arbeit nicht geschrieben: die Förderarbeit zählt allein.
|
||||
Assert.Equal(["3"], ExamScoringService.EffectiveGrades([(support, "3")], GradingSystem.Grades1To6));
|
||||
// Liegen beide Noten vor, zählt die erste — auch wenn die Förderarbeit besser ist.
|
||||
Assert.Equal(["4"], ExamScoringService.EffectiveGrades(
|
||||
[(original, "4"), (support, "1")], GradingSystem.Grades1To6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MehrereFolgearbeiten_ZaehlenGenauEinmal()
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class GroupRolloverServiceTests
|
||||
var oldMembership = new GroupMembership
|
||||
{
|
||||
StudentId = Guid.NewGuid(), GroupId = source.Id, Period = MembershipPeriod.H2Only,
|
||||
JoinedAt = new DateOnly(2026, 2, 1), LeftAt = new DateOnly(2026, 7, 31), Niveau = Niveau.E,
|
||||
JoinedAt = new DateOnly(2026, 2, 1), LeftAt = new DateOnly(2026, 7, 31), Niveau = Niveau.E, IsSupportStudent = true,
|
||||
};
|
||||
var groups = new MemoryGroups([source]);
|
||||
var memberships = new MemoryMemberships([oldMembership]);
|
||||
@@ -48,6 +48,7 @@ public sealed class GroupRolloverServiceTests
|
||||
Assert.Equal(new DateOnly(2026, 8, 1), copied.JoinedAt);
|
||||
Assert.Null(copied.LeftAt);
|
||||
Assert.Equal(Niveau.E, copied.Niveau);
|
||||
Assert.True(copied.IsSupportStudent);
|
||||
|
||||
var scheme = schemes.GetByGroup(target.Id);
|
||||
Assert.NotNull(scheme);
|
||||
|
||||
@@ -340,6 +340,7 @@ public sealed class WebUntisClientTests
|
||||
Assert.Contains("reports.do?name=AbsencePerStudent", handler.Requests[1].Uri);
|
||||
Assert.Contains("klasseOrStudentgroupId=KL874", handler.Requests[1].Uri);
|
||||
Assert.Contains("studentId=-1", handler.Requests[1].Uri);
|
||||
Assert.Contains("&separateAbsentDays=true&", handler.Requests[1].Uri);
|
||||
Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
|
||||
Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
|
||||
Assert.EndsWith("reports.do?get=rpt3.tmp&name=AbsencePerStudent&format=csv", handler.Requests[2].Uri);
|
||||
|
||||
@@ -208,7 +208,7 @@ public sealed partial class WebUntisClient : IAsyncDisposable
|
||||
"&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" +
|
||||
"&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" +
|
||||
"&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" +
|
||||
"&classExcuseStatusId=-1&_absClassSorted=on&_separateAbsentDays=on&_filterAbsencesKlasse=on" +
|
||||
"&classExcuseStatusId=-1&_absClassSorted=on&separateAbsentDays=true&_separateAbsentDays=on&_filterAbsencesKlasse=on" +
|
||||
"&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" +
|
||||
"&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" +
|
||||
"&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" +
|
||||
|
||||
@@ -6,6 +6,14 @@ Jeder Punkt ist so formuliert, dass er einzeln an einen Agenten übergeben werde
|
||||
**Stand:** 2026-08-18
|
||||
**Legende:** `[ ]` offen · `[~]` teilweise umgesetzt · `[x]` fertig
|
||||
|
||||
**Repository-Änderung 19.10.2026:** Das bisherige `ai-backend/` wurde mit 17 Backend-Commits
|
||||
in das eigenständige [LehrerBackend](https://git.science-teaching.de/admin/LehrerBackend)
|
||||
ausgegliedert. Dort liegen jetzt sämtliche PHP-Endpunkte, Anbieter und die Betriebsanleitung.
|
||||
Lokaler Checkout: `../LehrerBackend/`; Serveradresse und Desktop-Vertrag bleiben unverändert.
|
||||
Historische Nennungen von `ai-backend/...` unten beziehen sich auf Dateien im neuen Backend.
|
||||
[Wegweiser](ai-backend/README.md) ·
|
||||
[Betrieb](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/docs/betrieb.md).
|
||||
|
||||
---
|
||||
|
||||
## 0. Konventionen für alle Aufgaben
|
||||
@@ -51,6 +59,26 @@ Diese Regeln gelten für jede Aufgabe unten und müssen nicht wiederholt werden.
|
||||
nicht in Klausurwochen-Belastung und Vorjahresvergleich. Folgearbeiten erben die Art.
|
||||
Bestehende Klausuren bleiben unverändert (Default `Klausur`).
|
||||
|
||||
- [x] **Nachtrag 2026-10: Förderarbeit als dritte Folgearbeits-Art.** Eine Förderarbeit
|
||||
(`Exam.IsSupportWork`) ist eine inhaltlich eigene Arbeit, die einzelne Schüler statt der
|
||||
parallel geschriebenen ersten Arbeit schreiben (Workflow: erste Arbeit per „Duplizieren“
|
||||
kopieren, Aufgaben/Schlüssel anpassen, im Arbeitsdialog mit der ersten Arbeit verknüpfen).
|
||||
Der Arbeitsdialog ersetzt die Nachschreib-Checkbox durch eine Auswahl
|
||||
Wiederholung/Nachschreibarbeit/Förderarbeit; die Förderarbeit darf am selben Tag wie die erste
|
||||
Arbeit liegen (nur *vor* der ersten Arbeit ist gesperrt) und übernimmt beim Auswählen dessen
|
||||
Datum als änderbaren Vorschlag. Sie zählt wie eine Nachschreibarbeit
|
||||
(`ExamFollowUpExtensions.ReplacesMissingGrade`): ihre Note ersetzt nur eine fehlende Note, die
|
||||
Punkteeingabe blendet Schüler mit Note der ersten Arbeit aus — Schüler ohne Eintrag in der
|
||||
ersten Arbeit (z. B. Kranke) lassen sich mit „Rest als abwesend markieren“ ausblenden.
|
||||
Nachtrag: `GroupMembership.IsSupportStudent` markiert Förderschüler je Gruppe, auch ohne
|
||||
Niveaudifferenzierung (Checkbox-Spalte „Förder“ in der Schülerliste; in differenzierten
|
||||
Gruppen zählt das Förderniveau). Die Punkteeingabe/Deckblätter einer Förderarbeit zeigen nur
|
||||
Förderschüler (plus Schüler mit vorhandenem Ergebnis), das Kranke-Ausblenden entfällt damit.
|
||||
Fix Punkteeingabe: eine nur angefahrene Zeile (Fokus rein/raus ohne Eingabe) legt kein
|
||||
Ergebnis mit 0 Punkten und Note 6 mehr an (`ExamGradingDialogViewModel.SaveRow` speichert nur
|
||||
bei Wert, Abwesenheit, Kommentar oder vorhandenem Ergebnis; `PointsCell.TrySetValue` meldet
|
||||
nur echte Änderungen; Zeilen ohne Werte haben keine Note und speichern keine Punkteliste).
|
||||
|
||||
Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits in
|
||||
[Exam.cs](LehrerApp.Core/Models/Exam.cs), Repositories ebenfalls. Die komplette UI fehlt.
|
||||
|
||||
@@ -934,10 +962,10 @@ befüllt — `HazardSubstance` bekommt dafür drei neue Felder (`Cas`, `SignalWo
|
||||
(Nutzervorgabe: "wir brauchen die KI gar nicht mehr im Backend. Es reicht die reine Datenbank."):
|
||||
- Das 123-seitige PDF wurde einmalig (per `pdfplumber`, Tabellen-Extraktion je Seite, robuste
|
||||
CAS-Nummer-Rekonstruktion über zeilenweise wrappende Bindestrich-Enden) in
|
||||
[data/stoffliste.json](../ai-backend/data/stoffliste.json) umgewandelt (~1800 Stoffe, ~760 KB).
|
||||
- Neuer Endpunkt [substance.php](../ai-backend/substance.php): prüft weiterhin das Bearer-Token
|
||||
[data/stoffliste.json](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/data/stoffliste.json) umgewandelt (~1800 Stoffe, ~760 KB).
|
||||
- Neuer Endpunkt [substance.php](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/substance.php): prüft weiterhin das Bearer-Token
|
||||
(`ai_backend_authenticate`), sucht den angefragten Namen aber nur noch exakt (normalisiert,
|
||||
auch gegen CAS-Nummer/verwandte CAS-Nummern) in [stoffliste.php](../ai-backend/stoffliste.php)
|
||||
auch gegen CAS-Nummer/verwandte CAS-Nummern) in [stoffliste.php](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/stoffliste.php)
|
||||
— bewusst **kein** Fuzzy-/Teilstring-Match, ein falscher Treffer wäre hier schlimmer als gar
|
||||
keiner. Kein KI-Aufruf, keine Kosten, keine Drittanbieter-Netzwerkabhängigkeit mehr; ohne
|
||||
Treffer liefert der Endpunkt einen klaren 404 statt eines Rateversuchs.
|
||||
@@ -1621,6 +1649,17 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
|
||||
Auch hier keine externe Schülerkennung, nur der Name — Namensabgleich soll deshalb zusätzlich auf
|
||||
Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler.
|
||||
Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut.
|
||||
- [x] **Klassenlehrer — einzelne Fehlstunden als ganze Fehltage (Oktober 2026):**
|
||||
- Nachkorrektur: Der Trennparameter und seine Regression gehören zum Fehlzeitenbericht,
|
||||
nicht zum gleich aufgebauten Klassenbuchbericht. Cacheversion 2 erneuert die alten Abrufe.
|
||||
Explizites früheres Gehen im Abwesenheitsgrund zählt als Teilfehlzeit. Kalender zeigen
|
||||
`G` für früheres Gehen und `T` für sonstige Teilfehlzeiten, mit Umfang/Status im Tooltip;
|
||||
Rot kennzeichnet weiterhin unentschuldigte Fehlzeiten. Ganze Fehltage behalten U/E/A.
|
||||
Beim Berichtabruf ist `separateAbsentDays=true` jetzt tatsächlich aktiviert; der bisherige
|
||||
Parameter `_separateAbsentDays=on` allein aktivierte die Checkbox nicht. Alte Fehlzeiten-Caches
|
||||
werden über eine Berichtversion neu geladen und auch im historischen Bereich ersetzt.
|
||||
Regressionen prüfen den Abrufparameter, zwei Fehlstunden sowie das Ersetzen alter Cacheeinträge.
|
||||
|
||||
- [x] **"Klassenlehrer"-Feature — Datenschicht (August 2026):** Zwei WebUntis-Berichte liefern, was
|
||||
für einen Klassenlehrer-Überblick fehlte. `WebUntisIntegrationService.GetForeignClassRegisterEventsAsync`
|
||||
ist das Spiegelbild von `GetOwnClassRegisterEventsAsync`: derselbe "-alle-"-Rohbericht
|
||||
@@ -2502,6 +2541,11 @@ zurückgestellt):
|
||||
vorgeschalteter Dialog sortiert laufende Einheiten zuerst, lässt aber auch direkt eine neue
|
||||
Einheit anlegen; damit ist die frühere offene Unit-Frage ohne stillschweigende Zuordnung gelöst.
|
||||
Neue Direktanlagen starten als `Entwurf`.
|
||||
Nachtrag (09.11.2026): Neue Einheit erst nach erfolgreicher Eingabevalidierung, aber vor
|
||||
der ersten Stunde speichern (auch beim Verknuepfen einer vorhandenen Stunde). Damit
|
||||
entfaellt das verwaiste Zeitfenster bis zum Schliessen des Dialogs; Abbruch bleibt ohne
|
||||
neue Einheit. 119 betroffene Desktop-Tests bestanden. Bereits verwaiste Stunden werden
|
||||
dadurch nicht automatisch neu zugeordnet.
|
||||
- [x] **4.5.3** Aus diesem Lesson-Viewer heraus weiter verzweigen können: in die Zeugnisnote/
|
||||
Bewertung der Gruppe, und in die Schnelldialoge für Mitarbeit sowie Anwesenheit/Hausaufgaben.
|
||||
**Umsetzung:** Zwei neue Buttons im Footer von `LessonViewerDialog` ("Zur Mitarbeit"/
|
||||
@@ -2582,6 +2626,16 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
|
||||
könnte eine halluzinierte Id im schlimmsten Fall eine fremde Lesson überschreiben), sondern
|
||||
immer als neue Lesson mit frischer Id behandelt — mit dediziertem Test abgesichert.
|
||||
|
||||
**Erweiterung 2026-10-22:** Unter dem Zugang stehen eine Featureübersicht der LehrerApp
|
||||
(einschließlich kostenloser RiSU-Stoffsuche), Verbrauch nach Aktion mit Anteilsbalken und
|
||||
einzelne berechnete Aufrufe der letzten 28 Tage. Gemeinsamer Zugang: Auch Verbrauch anderer
|
||||
Apps wird über `api/v1/usage.php` angezeigt. Aktualisieren lädt Guthaben und Verbrauch neu;
|
||||
ältere Backends ohne Verbrauchsendpunkt bleiben nutzbar. USD werden korrekt als USD
|
||||
angezeigt, Euro nur bei mitgeliefertem Gegenwert. Keine zusätzlichen Kostenbestätigungen;
|
||||
der Kostenhinweis steht zentral in den Einstellungen, die Übernahme von Vorschlägen bleibt
|
||||
eine eigene bewusste Aktion. Die Übersicht behauptet keine Backend-Freigaben: Die bisherige
|
||||
Capabilities-Schnittstelle gilt nur für Arbeitsblatt-Funktionen.
|
||||
|
||||
Neue Einstellungen-Tab "KI-Unterstützung": Aktivieren-Schalter, Benutzername/Passwort →
|
||||
Anmelden tauscht das Passwort gegen ein Bearer-Token (nie das Passwort selbst persistiert).
|
||||
Das Token liegt lokal AES-256-verschlüsselt über das bestehende `SyncCrypto` (gleiches
|
||||
@@ -4854,6 +4908,12 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
|
||||
aus der Vorlage und ist im Dialog je Druck überschreibbar, ohne die Einstellung zu ändern —
|
||||
für gemeinsam unterrichtete Kurse mit zwei Kürzeln.
|
||||
|
||||
Nachgezogen (Nutzer-Wunsch): Der Dialog hat zusätzlich ein Feld "Titel", vorbelegt mit dem
|
||||
internen Klausurtitel und nur für diesen Druck änderbar (`CoverTitle` →
|
||||
`ExamCoverPlaceholderBuilder.Build(title:)` → `Exam.Title`) — der interne Titel passt nicht
|
||||
immer aufs Deckblatt. Die Klausur selbst und der vorgeschlagene Dateiname bleiben beim
|
||||
internen Titel; ein leer gelassenes Feld fällt auf ihn zurück.
|
||||
|
||||
**Punktetabelle ohne Verkleinern:** `ExamPointsTableDrawingBuilder` zeichnet feste
|
||||
Spaltenbreiten und bricht in weitere Blöcke um. Damit "Gesamtpunkte"/"Note" darunter
|
||||
mitwandern, bekam `FLOWDRAWBOX` das Opt-in-Attribut `fit=content` (belegt nur die
|
||||
@@ -4870,6 +4930,13 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
|
||||
liefert zusätzlich die Seitenzahl je Datensatz; läuft ein Deckblatt wegen zu vieler Aufgaben
|
||||
auf eine zweite Seite, warnt der Dialog.
|
||||
|
||||
Nachgezogen (Nutzer-Wunsch): Fortschrittsbalken im Dialog, weil ein Klassensatz spürbar
|
||||
dauert. `GenerateAsync` setzt im Hintergrund (`Task.Run`), `RenderBatchToPdf` meldet über
|
||||
ein optionales `IProgress<int>` jeden gesetzten Datensatz; das abschließende
|
||||
`Document.Merge` meldet nichts, dort läuft der Balken unbestimmt ("Sammel-PDF wird
|
||||
zusammengefügt …"). Währenddessen sind beide Schaltflächen gesperrt und der Dialog lässt
|
||||
sich nicht schließen. Ein einzelnes Deckblatt wird dabei nicht mehr doppelt gesetzt.
|
||||
|
||||
**TemplateDesigner:** "Als Klausur-Deckblatt einrichten" setzt `report-type=exam-cover` und
|
||||
legt alle Deckblatt-Platzhalter mit Beispielwerten an; vorhandene Platzhalter bleiben, ein
|
||||
abweichender Typ wird gemeldet statt überschrieben. Neuer Sonderinhalt "Punktetabelle
|
||||
@@ -4882,6 +4949,27 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
|
||||
`ExamCoverSheetTests` hält Katalog und `ExamCoverPlaceholderBuilder` zusammen. Beim Öffnen
|
||||
einer Deckblatt-Vorlage bekommen bekannte Platzhalter sprechende Beispielwerte statt
|
||||
"Beispielwert".
|
||||
- [x] **11.5e** Erweiterte Deckblatt-Bausteine ([Gitea #1](https://git.science-teaching.de/admin/LehrerApp/issues/1)):
|
||||
`Exam.RepeatInfo` zeigt die verknüpfte erste Arbeit, die bisherige Note und die Bewertungsregel
|
||||
(inklusive Nachschreibarbeiten). `Student.InterimGrades` druckt mündlich/schriftlich/vorläufige
|
||||
Zeugnisnote aus einem im Deckblatt-Dialog ausdrücklich ausgewählten gespeicherten Durchgang.
|
||||
Fehlende Zwischenstände ausgewählter Schüler blockieren den Druck mit Namensliste.
|
||||
Beide Inhalte sind im TemplateDesigner als Sonderinhalt mit echten Beispieldaten verfügbar.
|
||||
- [~] **11.5f** Schülerbericht für Kollegen ([Gitea #2](https://git.science-teaching.de/admin/LehrerApp/issues/2)):
|
||||
Im Schülerdetail über „Schülerbericht“: Lerngruppe, Zeitraum und einzelne Bausteine wählen;
|
||||
Standardvorlage ohne Import oder eigene `.lavorlage` mit `report-type=student-report`.
|
||||
Enthalten sind gespeicherter Notenzwischenstand, Mitarbeitsgraph (gewichtete Mitarbeit und
|
||||
Einzelaspekte aus derselben Berechnung wie der Assistent), Kompetenzbewertungen,
|
||||
Hausaufgabenkontrollen und Dokumentation/Unterrichtsnotizen. Der Bericht bezieht sich auf
|
||||
genau einen Schüler und eine Lerngruppe; Dokumentationsentwürfe und gelöschte Einträge bleiben
|
||||
ausgeschlossen, vertrauliche Dokumentation wird nur auf ausdrückliche Auswahl übernommen.
|
||||
Lange Texte laufen über mehrere Seiten, feste Zeichenboxen melden Platzmangel statt Text
|
||||
still abzuschneiden. Der TemplateDesigner bietet alle Bausteine und „Als Schülerbericht
|
||||
einrichten“. Ergänzende Texte können manuell eingegeben werden.
|
||||
**Noch offen/optional:** KI-generierte Textbausteine; hierfür enthält Issue #2 bisher nur eine
|
||||
Idee, keinen festgelegten Prompt oder Übernahmeablauf. Hausaufgabenstatus und Dokumentation
|
||||
zeigen ihren aktuellen gespeicherten Zustand im gewählten Zeitraum, keinen historischen
|
||||
Änderungsstand zu einem vergangenen Stichtag.
|
||||
- [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung).
|
||||
|
||||
---
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Sperrt alles, was kein öffentlicher Endpunkt ist. Nur login.php / status.php / plan.php /
|
||||
# explain.php sollen von außen aufrufbar sein. Siehe README.md — noch robuster ist es,
|
||||
# config.php/db.php/schema.sql/providers/ komplett außerhalb des Webroots abzulegen, falls das
|
||||
# Hosting das erlaubt.
|
||||
|
||||
<FilesMatch "^(config(\.example)?\.php|db\.php)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
<Files "schema.sql">
|
||||
Require all denied
|
||||
</Files>
|
||||
|
||||
<FilesMatch "\.md$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
RewriteEngine On
|
||||
RewriteRule ^providers/ - [F,L]
|
||||
RewriteRule ^scripts/ - [F,L]
|
||||
|
||||
# Viele Apache/PHP-FPM-Setups reichen den Authorization-Header standardmäßig NICHT an PHP durch
|
||||
# (login.php funktioniert dann, aber jede Anfrage mit Bearer-Token danach schlägt fehl, weil der
|
||||
# Header bei PHP leer ankommt). Diese Zeile erzwingt die Weiterleitung — zusammen mit dem
|
||||
# Mehrquellen-Fallback in db.php (ai_backend_authorization_header) deckt das die gängigen
|
||||
# Hosting-Varianten ab.
|
||||
RewriteCond %{HTTP:Authorization} ^(.+)$
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%1]
|
||||
+17
-249
@@ -1,254 +1,22 @@
|
||||
# KI-Backend (TODO 4.5.9)
|
||||
# Backend umgezogen
|
||||
|
||||
Kleines PHP-Zwischenelement, das die KI-gestützte Planungsunterstützung des Desktop-Clients
|
||||
absichert: der LLM-API-Key liegt nur hier auf dem Server, der Desktop-Client bekommt nur ein
|
||||
eigenes Bearer-Token gegen dieses Backend und ein pro Nutzer geführtes Guthaben. Deployment
|
||||
macht der Nutzer selbst — dieses README beschreibt die nötigen Schritte.
|
||||
Seit dem 19.10.2026 wird das frühere KI-Backend als eigenständiges, gemeinsam genutztes
|
||||
[LehrerBackend](https://git.science-teaching.de/admin/LehrerBackend) gepflegt.
|
||||
|
||||
## Voraussetzungen
|
||||
- [Quellcode und Übersicht](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/README.md)
|
||||
- [Einrichtung und Betrieb](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/docs/betrieb.md)
|
||||
- [Umzug und Git-Historie](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/docs/migration.md)
|
||||
|
||||
- PHP 8.1 oder neuer (nutzt `never`-Rückgabetypen, `match`, First-Class-Callable-Syntax nicht,
|
||||
aber typisierte Properties/Enums nicht zwingend — 8.1 ist die sichere Untergrenze).
|
||||
- PHP-Erweiterungen: `pdo_mysql`, `curl`, `json` (bei den meisten Hosting-Paketen bereits dabei).
|
||||
- Eine MySQL- oder MariaDB-Datenbank.
|
||||
- Ein API-Key für Anthropic (`https://console.anthropic.com`).
|
||||
Lokal liegt es neben diesem Repository unter `../LehrerBackend/`.
|
||||
Änderungen an PHP-Endpunkten, Anbietern, Authentifizierung und Abrechnung gehören dorthin.
|
||||
Die Desktop-Anbindung und ihre Datenmodelle bleiben in LehrerApp.
|
||||
Es gibt keine Submodule und keine zusätzliche Build-Abhängigkeit.
|
||||
|
||||
## Einrichtung
|
||||
Serveradresse, Datenbank, Nutzerkonten und bestehende Endpunkte bleiben durch den Umzug
|
||||
unverändert. Die Konfiguration liegt lokal ausschließlich in `../LehrerBackend/config.php`
|
||||
und wird nicht eingecheckt. Produktionsupdates künftig aus LehrerBackend durchführen.
|
||||
|
||||
1. Datenbank anlegen und `schema.sql` importieren:
|
||||
```bash
|
||||
mysql -u <user> -p <datenbankname> < schema.sql
|
||||
```
|
||||
2. `config.example.php` nach `config.php` kopieren und ausfüllen (DB-Zugang, Anthropic-API-Key,
|
||||
ggf. die Preistabelle gegen die aktuelle Anthropic-Preisseite prüfen — Preise ändern sich).
|
||||
`config.php` ist in `.gitignore` und darf nie committet werden.
|
||||
3. Falls du die Web-Variante aus Schritt 5 nutzen willst (kein PHP-CLI/SSH auf dem Hosting):
|
||||
`setup_secret` in `config.php` auf einen langen Zufallswert setzen (z.B. per
|
||||
`openssl rand -hex 32` erzeugt, oder ein beliebiger Passwortgenerator).
|
||||
4. Den kompletten `ai-backend/`-Ordner auf den PHP-Server hochladen. **Empfehlung:** `config.php`,
|
||||
`db.php`, `schema.sql`, `providers/` und `scripts/` außerhalb des öffentlichen Webroots ablegen,
|
||||
falls das Hosting das erlaubt (Pfade in den `require`-Aufrufen entsprechend anpassen) — robuster
|
||||
als sich allein auf die mitgelieferte `.htaccess` zu verlassen, die nur bei Apache mit
|
||||
aktiviertem `mod_rewrite`/erlaubten `.htaccess`-Overrides greift.
|
||||
5. Ersten Nutzer anlegen (weitere Lehrer später genauso) — zwei Varianten, je nachdem was das
|
||||
Hosting hergibt:
|
||||
|
||||
**Mit PHP-CLI/SSH-Zugriff** (`scripts/create-user.php`):
|
||||
```bash
|
||||
php scripts/create-user.php sebastian "einStarkesPasswort" 10.00
|
||||
```
|
||||
Der dritte Parameter ist das Startguthaben in USD, optional (Standard 0).
|
||||
|
||||
**Ohne CLI/SSH** (übliches Shared Hosting, nur FTP/Webspace) — der Server läuft nach Schritt 4
|
||||
bereits, also einmalig von deinem eigenen Rechner aus aufrufen:
|
||||
```bash
|
||||
curl -X POST https://DEINE-DOMAIN/setup-user.php \
|
||||
-d '{"secret":"<setup_secret aus config.php>","username":"sebastian","password":"einStarkesPasswort","balanceUsd":10.00}'
|
||||
```
|
||||
**Direkt danach `setup-user.php` per FTP wieder vom Server löschen** — sonst bleibt ein
|
||||
Endpunkt erreichbar, der (mit dem Geheimnis) beliebige Nutzer anlegen kann. Ein erneuter
|
||||
Aufruf mit demselben Benutzernamen aktualisiert Passwort/Guthaben, falls z.B. das Guthaben
|
||||
beim ersten Versuch vergessen wurde — dafür braucht es die Datei aber kein zweites Mal auf
|
||||
dem Server, einfach vor dem Löschen alle nötigen Nutzer in einer Sitzung anlegen.
|
||||
6. In `LehrerApp.Desktop/AppBootstrapper.cs` die Konstante `AiBackendUrl` auf die tatsächlich
|
||||
deployte Domain setzen und die App neu bauen.
|
||||
7. In der App unter Einstellungen → KI-Unterstützung aktivieren und mit dem angelegten Nutzer
|
||||
anmelden.
|
||||
|
||||
## Update für bereits deployte Installationen (Prompt-Caching-Nachtrag)
|
||||
|
||||
Falls `ai-backend/` schon einmal deployt wurde (schema.sql lief bereits, `config.php` existiert
|
||||
schon): zwei Schritte nötig, damit Abrechnung und `plan.php` mit dem neuen Prompt Caching
|
||||
funktionieren.
|
||||
|
||||
1. Migration einmalig einspielen (ergänzt zwei neue, nicht-destruktive Spalten):
|
||||
```bash
|
||||
mysql -u <user> -p <datenbankname> < migrations/2026-08-add-cache-tokens.sql
|
||||
```
|
||||
2. In der bestehenden `config.php` bei jedem Preistabellen-Eintrag `cache_write`/`cache_read`
|
||||
ergänzen (siehe `config.example.php` für aktuelle Richtwerte) — ohne diese Ergänzung wird der
|
||||
Cache-Anteil einfach mit 0 USD abgerechnet (kein Fehler, aber auch keine Kostenersparnis).
|
||||
|
||||
Danach alle geänderten Dateien (`plan.php`, `db.php`, `providers/`, `.htaccess`, falls noch nicht
|
||||
aktuell) erneut hochladen.
|
||||
|
||||
## Update für bereits deployte Installationen ("Schattenfeld"-Nachtrag, `explain.php`)
|
||||
|
||||
Kein neues DB-Schema nötig (nutzt dieselben `users`/`tokens`/`transactions`-Tabellen wie `plan.php`).
|
||||
Einfach die neue Datei `explain.php` sowie die aktualisierten `db.php` und `plan.php` hochladen
|
||||
(die Abrechnungslogik wurde aus `plan.php` in eine gemeinsame Funktion `ai_backend_call_and_charge`
|
||||
in `db.php` verschoben, damit `explain.php` sie mitverwenden kann, ohne sie zu duplizieren).
|
||||
|
||||
## Update für bereits deployte Installationen (Gefährdungsbeurteilungs-Entwurf, `gbu.php`)
|
||||
|
||||
Kein neues DB-Schema nötig (nutzt dieselben `users`/`tokens`/`transactions`-Tabellen wie
|
||||
`plan.php`/`explain.php`). Einfach die neue Datei `gbu.php` hochladen.
|
||||
|
||||
## Update für bereits deployte Installationen (Chemikalien-Recherche, `substance.php`)
|
||||
|
||||
Neues Feature — reiner lokaler Datenbank-Lookup, **kein KI-Aufruf, keine Kosten, keine
|
||||
Drittanbieter-Abhängigkeit** (Nutzerwunsch: "wir brauchen die KI gar nicht mehr im Backend").
|
||||
|
||||
Vorgeschichte in Kurzform (Details siehe TODO.md, Nachtrag zu 4.2): zunächst wurde versucht, die
|
||||
Daten per KI-Websuche bei GESTIS zu recherchieren — das scheiterte, weil `gestis.dguv.de` eine
|
||||
reine JavaScript-Anwendung ohne durchsuchbaren/abrufbaren Inhalt ist. Ein direkter Abruf über
|
||||
GESTIS' eigene (inoffizielle) API funktionierte zwar technisch, hatte aber keine strukturierte,
|
||||
schulspezifische Tätigkeitsbeschränkung (Freigabe/Verbot/Altersgrenzen) — genau die für den
|
||||
Unterricht wichtigste Angabe. Der Nutzer stellte stattdessen die offizielle **RiSU-Stoffliste**
|
||||
("Richtlinie Sicheres Experimentieren im Unterricht") als PDF bereit — ein für den Schuleinsatz
|
||||
zusammengestelltes Verzeichnis mit exakt den benötigten Spalten (Name, CAS-Nr., GHS-Piktogramme,
|
||||
Signalwort, H-/P-Sätze UND Tätigkeitsbeschränkung), aus dem `data/stoffliste.json` (~1800 Stoffe)
|
||||
erzeugt wurde. `substance.php` ist damit auf einen reinen, kostenlosen Datenbank-Lookup reduziert.
|
||||
|
||||
**Deployment:** neue Dateien `substance.php` und `stoffliste.php` hochladen, sowie
|
||||
`data/stoffliste.json` (~760 KB, nur lesend benötigt). Kein Migrations-Schritt, keine
|
||||
`config.php`-Änderung nötig — der Endpunkt prüft weiterhin das Bearer-Token wie jeder andere
|
||||
(`ai_backend_authenticate`), braucht aber keinerlei KI-/Abrechnungs-Infrastruktur mehr.
|
||||
|
||||
**Legende der Tätigkeitsbeschränkungs-Codes** (steht in keiner Form im PDF selbst, vom Nutzer
|
||||
bestätigt — Übersetzung passiert zur Abfragezeit in `stoffliste.php`
|
||||
(`STOFFLISTE_RESTRICTION_LEGEND`), bewusst NICHT in `data/stoffliste.json` gespeichert, damit eine
|
||||
künftige Korrektur nur eine Codeänderung statt einer neuen Datenbank-Generierung braucht — genau
|
||||
das war hier schon einmal nötig, die erste Fassung dieser Legende war falsch):
|
||||
|
||||
| Code | Bedeutung |
|
||||
| --- | --- |
|
||||
| `+` | Generelle Erlaubnis |
|
||||
| `X` | Generelles Tätigkeitsverbot an Schulen |
|
||||
| `L+` | Tätigkeitsverbot für Lehrkräfte, Ausnahme siehe RiSU (I – 3.5) |
|
||||
| `S` | Tätigkeitsverbot für Schülerinnen und Schüler |
|
||||
| `S4K` | Tätigkeitsverbot für Schülerinnen und Schüler bis einschließlich Jahrgangsstufe 4 |
|
||||
| `S9K` | Tätigkeitsverbot für Schülerinnen und Schüler bis einschließlich Jahrgangsstufe 9 |
|
||||
| `W` | Tätigkeitsverbot für werdende oder stillende Mütter |
|
||||
| `ESP` | Gesonderte Ersatzstoffprüfung notwendig |
|
||||
|
||||
**Bekannte Lücken:**
|
||||
- Diese Stoffliste enthält keine Entsorgungshinweise (anders als der frühere GESTIS-Ansatz) — das
|
||||
`disposal`-Feld im Wizard bleibt bei einem Datenbank-Treffer leer und muss weiterhin von Hand
|
||||
ausgefüllt werden.
|
||||
- Für ca. 40 der ca. 1800 Stoffe im PDF (~2 %) konnte die Tabellenerkennung die Spalten nicht
|
||||
zuverlässig auseinanderhalten (ungewöhnlich lange Zellinhalte lassen `pdfplumber`s
|
||||
Spaltenerkennung für einzelne Zeilen kollabieren) — betroffene Zeilen wurden NICHT mit
|
||||
geratenen/möglicherweise falschen Werten aufgenommen, sondern bewusst ausgelassen (ein falscher
|
||||
Stoffname bei sonst korrekten Gefahrendaten wäre gefährlicher als ein fehlender Eintrag). Bei
|
||||
Bedarf lässt sich die Liste der fehlenden Stoffe aus dem PDF neu ermitteln.
|
||||
|
||||
**Aktualisieren der Datenbank:** wenn die RiSU-Stoffliste künftig überarbeitet wird, das PDF neu
|
||||
herunterladen und `data/stoffliste.json` neu erzeugen (Parsing-Logik ist nicht Teil des Repos,
|
||||
da einmalig genutzt — bei Bedarf erneut über `pdfplumber` extrahieren, Spaltenreihenfolge siehe
|
||||
`stoffliste.php`-Kommentar). Die Tätigkeitsbeschränkungs-**Codes** werden weiterhin mit
|
||||
gespeichert, nur ihre Übersetzung lebt separat in `STOFFLISTE_RESTRICTION_LEGEND`.
|
||||
|
||||
## Prompt Caching
|
||||
|
||||
Der Systemprompt in `plan.php` ist vollständig statisch (identisch bei jeder Anfrage, jedes
|
||||
Nutzers). `AnthropicProvider.php` markiert ihn deshalb als cacheable — wiederholte Anfragen
|
||||
innerhalb der Anthropic-Cache-TTL (Standard: 5 Minuten) zahlen für diesen Anteil nur den stark
|
||||
reduzierten "Cache-Read"-Preis statt des vollen Input-Preises; der erste Aufruf nach TTL-Ablauf
|
||||
zahlt einen kleinen Aufpreis fürs Neuschreiben des Caches. Das passiert automatisch, ohne
|
||||
Zutun — nichts an der Bedienung der App ändert sich dadurch. Beobachtbar ist der Effekt in der
|
||||
`transactions`-Tabelle (`cache_creation_input_tokens` vs. `cache_read_input_tokens`).
|
||||
|
||||
## Smoke-Test ohne echten API-Key
|
||||
|
||||
`plan.php` liest die Umgebungsvariable `AI_BACKEND_FAKE_PROVIDER` — bei `1` wird statt eines
|
||||
echten Anthropic-Aufrufs `providers/FakeProvider.php` verwendet (liefert eine feste,
|
||||
schema-valide Test-Antwort). Damit lassen sich Login, Guthabenprüfung und die 402-Pfade lokal
|
||||
prüfen, ohne echte Kosten zu verursachen:
|
||||
|
||||
```bash
|
||||
AI_BACKEND_FAKE_PROVIDER=1 php -S localhost:8000 -t .
|
||||
```
|
||||
|
||||
Dann z.B.:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/login.php \
|
||||
-d '{"username":"sebastian","password":"einStarkesPasswort"}'
|
||||
|
||||
curl http://localhost:8000/status.php -H "Authorization: Bearer <token aus login.php>"
|
||||
```
|
||||
|
||||
**Wichtig:** `AI_BACKEND_FAKE_PROVIDER` niemals auf dem produktiven Server setzen — sonst bekommt
|
||||
die App nur die feste Test-Antwort statt echter KI-Vorschläge.
|
||||
|
||||
## Fehlerbehebung: "Anmeldung abgelaufen" direkt nach dem Anmelden
|
||||
|
||||
Login funktioniert, aber jede Anfrage danach (Guthaben, KI-Anfrage) meldet sofort eine
|
||||
abgelaufene/ungültige Anmeldung? Typische Ursache: viele Apache/PHP-FPM-Hosting-Setups reichen
|
||||
den `Authorization`-Header standardmäßig gar nicht erst an PHP durch — `login.php` braucht ihn
|
||||
nicht, aber `status.php`/`plan.php` schon, und der kommt dort leer an.
|
||||
|
||||
Dagegen sind bereits zwei Absicherungen eingebaut:
|
||||
- `.htaccess` erzwingt die Weiterleitung des Headers (`RewriteRule .* - [E=HTTP_AUTHORIZATION:%1]`).
|
||||
- `db.php` liest zusätzlich `REDIRECT_HTTP_AUTHORIZATION` und `getallheaders()` als Fallback.
|
||||
|
||||
Falls es trotzdem weiter auftritt: `.htaccess`-Overrides sind auf manchem Hosting serverseitig
|
||||
deaktiviert (`AllowOverride None`), oder es läuft nginx statt Apache (dort gilt `.htaccess`
|
||||
grundsätzlich nicht) — dann hilft nur eine serverseitige Konfiguration durch den Hoster/Support
|
||||
(z.B. bei nginx ein `fastcgi_param HTTP_AUTHORIZATION $http_authorization;`).
|
||||
|
||||
## Update für bereits deployte Installationen (Fehlzeiten-Statusvorschlag, `untis-status.php`)
|
||||
|
||||
Kein neues DB-Schema nötig (nutzt dieselben `users`/`tokens`/`transactions`-Tabellen und dieselbe
|
||||
`ai_backend_call_and_charge`-Abrechnung wie `plan.php`/`explain.php`/`gbu.php`). Einfach die neue
|
||||
Datei `untis-status.php` hochladen. Bewusst ohne jeden Personenbezug im Payload (Nutzer-Vorgabe zum
|
||||
Untis-Hub) — die Zeilen enthalten nur eine rein technische Positions-Id sowie Fehlminuten/
|
||||
Bearbeitet-Kennzeichen/ENr-Klammerung und den Freitext-Grund, nie Name, Klasse oder Datum.
|
||||
|
||||
## Fehlerbehebung: KI-Antwort endet mitten im JSON
|
||||
|
||||
Eine im Rettungsdialog am Ende abgeschnittene Modellantwort entsteht typischerweise nicht durch
|
||||
die Socketverbindung, sondern durch Anthropics `stop_reason: max_tokens`. Das Backend erkennt
|
||||
diesen Fall ausdrücklich und nennt das konfigurierte Limit in der Fehlermeldung. Der empfohlene
|
||||
Wert für `max_output_tokens` ist **16000**; abgerechnet werden trotzdem nur tatsächlich erzeugte
|
||||
Tokens.
|
||||
|
||||
Für echte Zeitüberschreitungen wartet der Anthropic-cURL-Aufruf höchstens 180 Sekunden und der
|
||||
Desktop-Client 210 Sekunden. Bricht eine Anfrage reproduzierbar früher ab, liegt davor noch ein
|
||||
Zeitlimit des Hosters (nginx/Apache/FastCGI/Reverse Proxy), das auf mindestens 180 Sekunden gesetzt
|
||||
werden muss.
|
||||
|
||||
## Was hiermit NICHT geprüft ist
|
||||
|
||||
- Ob Anthropic zuverlässig valides JSON im erwarteten Schema liefert (reine Prompt-Qualitätsfrage,
|
||||
nur mit dem echten API-Key zu beurteilen).
|
||||
- Ob die berechneten Kosten exakt mit der tatsächlichen Anthropic-Abrechnung übereinstimmen.
|
||||
- TLS/`.htaccess`-Wirksamkeit und PHP-Version/Erweiterungen auf dem tatsächlichen Hosting.
|
||||
- Die komplette Kette Desktop → dieses Backend → Anthropic unter echten Netzwerkbedingungen.
|
||||
- Ob Prompt Caching tatsächlich greift (`cache_read_input_tokens` > 0 bei einer zweiten Anfrage
|
||||
innerhalb von 5 Minuten) — der `FakeProvider` simuliert kein Caching, das lässt sich nur gegen
|
||||
die echte Anthropic-API beobachten (z.B. per Blick in die `transactions`-Tabelle).
|
||||
- **`untis-status.php` (Fehlzeiten-Statusvorschlag):** dieselbe Einschränkung wie oben (echtes JSON
|
||||
im erwarteten Schema, korrekte Kostenberechnung) gilt hier ebenso, zusätzlich ungeprüft: ob das
|
||||
Modell die vorgegebene, feste Statusliste zuverlässig einhält statt eigene Werte zu erfinden
|
||||
(der Desktop-Client verwirft einen nicht in dieser Liste enthaltenen Wert defensiv, siehe
|
||||
`WebUntisLessonAbsenceRow.SelectableStatuses`), sowie ob die Id-Rückgabetreue in der Praxis so
|
||||
zuverlässig ist wie in den Tests angenommen (dort nur mit statischen Antworten simuliert).
|
||||
- **`substance.php`/`stoffliste.php` (Chemikalien-Recherche):** braucht keinen echten API-Key und
|
||||
keine Netzwerkverbindung mehr, ist also grundsätzlich einfacher zu verifizieren als der Rest
|
||||
dieser Liste — trotzdem NICHT in einer echten PHP-Umgebung gelaufen (kein PHP auf der
|
||||
Implementierungs-Maschine verfügbar). Die Lookup-/Normalisierungslogik in `stoffliste.php` sowie
|
||||
der PDF→JSON-Umwandlungsschritt für `data/stoffliste.json` wurden in Python nachgebaut und
|
||||
gegen Stichproben verifiziert (u.a. Natrium, Kaliumdichromat, Zinn-Verbindungen), nicht aber der
|
||||
tatsächlich ausgeführte PHP-Code selbst — vor dem produktiven Einsatz einmal mit ein paar
|
||||
bekannten Stoffnamen durchtesten.
|
||||
**Nachtrag (Nutzer-Testlauf):** eine harte `allowed_domains`-Einschränkung auf GESTIS lieferte
|
||||
für tatsächlich vorhandene Stoffe trotzdem "nicht sicher auffindbar" — vermutlich liefert die
|
||||
Websuche für GESTIS' dynamisch aufgebaute Seiten zu wenige brauchbar indexierte Treffer.
|
||||
`allowed_domains` wurde daraufhin entfernt; die Quellenrangfolge (GESTIS zuerst, sonst andere
|
||||
anerkannte Quellen) steht jetzt nur noch im Systemprompt, mit Pflichtangabe der tatsächlich
|
||||
genutzten Quelle im `source`-Feld. Ob GESTIS damit in der Praxis öfter tatsächlich als Quelle
|
||||
gefunden wird, ist weiterhin nur mit echten Testläufen zu beurteilen.
|
||||
|
||||
## Guthaben aufladen
|
||||
|
||||
Für den aktuellen Umfang (ein bis wenige Nutzer) reicht ein manueller SQL-Befehl:
|
||||
|
||||
```sql
|
||||
UPDATE users SET balance_usd = balance_usd + 10.00 WHERE username = 'sebastian';
|
||||
INSERT INTO transactions (user_id, type, cost_usd, balance_after)
|
||||
SELECT id, 'topup', -10.00, balance_usd FROM users WHERE username = 'sebastian';
|
||||
```
|
||||
|
||||
(Kein Admin-UI in dieser Ausbaustufe — bei Bedarf später ergänzbar.)
|
||||
Ältere Verweise auf `ai-backend/...` in Kommentaren und historischen Roadmap-Einträgen
|
||||
bezeichnen die entsprechenden Dateien im Hauptordner von LehrerBackend.
|
||||
Die bisherige Historie bleibt in LehrerApp erhalten und wurde zusätzlich für LehrerBackend
|
||||
extrahiert. In diesem Ordner liegt nur noch dieser Wegweiser.
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
// Kopieren nach config.php und mit echten Werten füllen. config.php selbst ist in .gitignore
|
||||
// eingetragen und darf nie committet werden.
|
||||
|
||||
return [
|
||||
'db' => [
|
||||
'host' => 'localhost',
|
||||
'name' => 'lehrerapp_ai',
|
||||
'user' => 'CHANGE_ME',
|
||||
'pass' => 'CHANGE_ME',
|
||||
],
|
||||
|
||||
// Nur für setup-user.php gebraucht (Web-Alternative zu scripts/create-user.php auf Hosting
|
||||
// ohne PHP-CLI/SSH) — ein langer Zufallswert, z.B. per `openssl rand -hex 32` erzeugt.
|
||||
// setup-user.php nach Gebrauch vom Server löschen, siehe README.md.
|
||||
'setup_secret' => 'CHANGE_ME',
|
||||
|
||||
// Nur 'anthropic' ist aktuell fertig implementiert (siehe providers/AnthropicProvider.php).
|
||||
// Die Provider-Schnittstelle ist so gebaut, dass ein OpenAiProvider später ergänzt werden
|
||||
// kann — bewusst nicht in dieser Runde, da aktuelle OpenAI-Preise/API-Version zum Zeitpunkt
|
||||
// der Implementierung nicht verifiziert wurden (siehe TODO.md, Nachtrag zu 4.5.9).
|
||||
'llm_provider' => 'anthropic',
|
||||
|
||||
'anthropic' => [
|
||||
'api_key' => 'sk-ant-CHANGE_ME',
|
||||
'model' => 'claude-sonnet-5',
|
||||
],
|
||||
|
||||
// Sicherheitsnetz gegen ausufernde Antworten (und damit Kosten) pro Anfrage.
|
||||
// 8.000 waren bei ausführlichen Einheitenplanungen nachweislich zu knapp und führten zu
|
||||
// mitten im JSON abgeschnittenen Antworten. Das ist eine Obergrenze, kein vorab berechneter
|
||||
// Festpreis: kürzere Antworten verbrauchen und kosten weiterhin nur ihre tatsächlichen Tokens.
|
||||
'max_output_tokens' => 16000,
|
||||
|
||||
// USD je 1 Million Token, getrennt nach Input/Output/Prompt-Cache. Vor dem produktiven Einsatz
|
||||
// gegen die aktuelle Anthropic-Preisseite gegenprüfen — Preise ändern sich.
|
||||
// "cache_write" = Aufpreis beim ersten Aufruf, der den (jetzt vollständig statischen)
|
||||
// Systemprompt neu in den Cache schreibt — Anthropic-Standard: 1,25× Input-Preis.
|
||||
// "cache_read" = deutlich reduzierter Preis für jeden Folgeaufruf innerhalb der Cache-TTL
|
||||
// (Anthropic-Standard: 5 Minuten), der den Systemprompt aus dem Cache liest statt neu zu
|
||||
// verarbeiten — Anthropic-Standard: 0,1× Input-Preis.
|
||||
'pricing' => [
|
||||
'claude-sonnet-5' => ['input' => 3.00, 'output' => 15.00, 'cache_write' => 3.75, 'cache_read' => 0.30],
|
||||
'claude-opus-5' => ['input' => 5.00, 'output' => 25.00, 'cache_write' => 6.25, 'cache_read' => 0.50],
|
||||
'claude-haiku-4-5' => ['input' => 1.00, 'output' => 5.00, 'cache_write' => 1.25, 'cache_read' => 0.10],
|
||||
],
|
||||
];
|
||||
@@ -1 +0,0 @@
|
||||
[{"name":"SSttooffff--//GGeemmiisscchh&&sshhyy;;bbeezzeeiicchhuunngg && IIUUPPAACC","cas":"","relatedCas":[],"zvg":"ZZVVGG","signalWord":"SSiiggnnaall&&sshhyy;;\nwwoorrtt","ghsPictograms":["GGeeffaahhrreenn&&sshhyy;;","ppiikkttoo&&sshhyy;;","ggrraammmm"],"hStatements":["HH-- uunndd EEUUHH--SSaattzz"],"pStatements":["PP--SSaattzz"],"hazardCategories":["PPhhrraasseenn"],"activityRestrictionCodes":["TTäättiiggkkeeiittss&&sshhyy;;","bbeesscchhrräänn&&sshhyy;;","kkuunnggeenn"]},{"name":"Abfälle, anorganische Salze und Lösungen, ohne CN und Schwermetalle","cas":"","relatedCas":[],"zvg":"17","signalWord":"Gefahr","ghsPictograms":["GHS05","GHS06","GHS08"],"hStatements":["H301","H311","H315","H317","H331","H341","H373"],"pStatements":[],"hazardCategories":["Giftig","Reizend","Allergisierend bei Hautkontakt","KMR-Stoff Kat. 2","Kann die Organe schädigen"],"activityRestrictionCodes":["S4K","W"]},{"name":"Abfälle, organisch fest","cas":"","relatedCas":[],"zvg":"11","signalWord":"Gefahr","ghsPictograms":["GHS05","GHS06","GHS08","GHS09"],"hStatements":["H314","H317","H335","H341","H411"],"pStatements":["P260","P262","P280","P501","P301+P310"],"hazardCategories":["Ätzend/Korrosiv","Allergisierend bei Hautkontakt","Reizend","KMR-Stoff Kat. 2","Gewässergefährdend"],"activityRestrictionCodes":["S4K","W"]},{"name":"Abfälle, organisch flüssig halogenfrei","cas":"","relatedCas":[],"zvg":"12","signalWord":"Gefahr","ghsPictograms":["GHS02","GHS05","GHS06","GHS08","GHS09"],"hStatements":["H225","H314","H317","H335","H341","H411"],"pStatements":["P210","P260","P262","P280","P501","P301+P310"],"hazardCategories":["Leicht entzündbar","Ätzend/Korrosiv","Allergisierend bei Hautkontakt","Reizend","KMR-Stoff Kat. 2","Gewässergefährdend"],"activityRestrictionCodes":["S4K","W"]},{"name":"Abfälle, organisch flüssig halogenhaltig","cas":"","relatedCas":[],"zvg":"13","signalWord":"Gefahr","ghsPictograms":["GHS02","GHS05","GHS06","GHS08","GHS09"],"hStatements":["H225","H314","H317","H335","H341","H411"],"pStatements":["P210","P260","P262","P280","P501","P301+P310"],"hazardCategories":["Leicht entzündbar","Ätzend/Korrosiv","Allergisierend bei Hautkontakt","Reizend","KMR-Stoff Kat. 2","Gewässergefährdend"],"activityRestrictionCodes":["S4K","W"]},{"name":"Abfälle, organische Peroxide","cas":"","relatedCas":[],"zvg":"10","signalWord":"Gefahr","ghsPictograms":["GHS01","GHS05","GHS06","GHS08","GHS09"],"hStatements":["H241","H314","H317","H331","H373","H411"],"pStatements":["P210","P260","P262","P273","P280","P501","P301+P312","P302+P352"],"hazardCategories":["Explosiv","Ätzend/Korrosiv","Allergisierend bei Hautkontakt","Giftig","Kann die Organe schädigen","Gewässergefährdend"],"activityRestrictionCodes":["S","W"]},{"name":"Abfälle, Quecksilbermetall, mit Glasbruch oder Adsorbens","cas":"","relatedCas":[],"zvg":"14","signalWord":"Gefahr","ghsPictograms":["GHS06","GHS08","GHS09"],"hStatements":["H330","H372","H410","H360D"],"pStatements":["P260","P273","P280","P314","P391","P405","P501"],"hazardCategories":["Lebensgefahr","Schädigt die Organe","Gewässergefährdend","KMR-Stoff Kat. 1"],"activityRestrictionCodes":["S","W"]},{"name":"Abfälle, Quecksilberverbindungen","cas":"","relatedCas":[],"zvg":"15","signalWord":"Gefahr","ghsPictograms":["GHS05","GHS06","GHS08","GHS09"],"hStatements":["H314","H317","H373","H410","H300+H310+H330"],"pStatements":["P260","P262","P273","P280","P501","P301+P310"],"hazardCategories":["Ätzend/Korrosiv","Allergisierend bei Hautkontakt","Kann die Organe schädigen","Gewässergefährdend","Lebensgefahr"],"activityRestrictionCodes":["S","W"]},{"name":"Abfälle, Schwermetallsalzlösungen, wässrig","cas":"","relatedCas":[],"zvg":"16","signalWord":"Gefahr","ghsPictograms":["GHS05","GHS06","GHS08","GHS09"],"hStatements":["H314","H373","H410","H360D","H300+H310+H330"],"pStatements":["P260","P262","P273","P280","P501","P301+P312","P302+P352"],"hazardCategories":["Ätzend/Korrosiv","Kann die Organe schädigen","Gewässergefährdend","KMR-Stoff Kat. 1","Lebensgefahr"],"activityRestrictionCodes":["S","W"]},{"name":"Acenaphthen 1,8-Ethylennaphthalin","cas":"83-32-9","relatedCas":[],"zvg":"23000","signalWord":"Achtung","ghsPictograms":["GHS09"],"hStatements":["H410"],"pStatements":["P273","P391","P501"],"hazardCategories":["Gewässergefährdend"],"activityRestrictionCodes":["+"]},{"name":"Acetaldehyd Ethanal","cas":"75-07-0","relatedCas":[],"zvg":"12760","signalWord":"Gefahr","ghsPictograms":["GHS02","GHS07","GHS08"],"hStatements":["H224","H319","H335","H341","H350"],"pStatements":["P202","P210","P233","P308+P313","P403+P233","P305+P351+P338"],"hazardCategories":["Extrem entzündbar","Reizend","KMR-Stoff Kat. 2","KMR-Stoff Kat. 1"],"activityRestrictionCodes":["X"]},{"name":"Acetaldehyd 1% Ethanal-Lösung w=1%","cas":"","relatedCas":[],"zvg":"12760.002","signalWord":"Gefahr","ghsPictograms":["GHS08"],"hStatements":["H341","H350"],"pStatements":["P201","P210","P308+P313","P403+P233","P305+P351+P338"],"hazarLine truncated
|
||||
@@ -1,200 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/providers/AnthropicProvider.php';
|
||||
require_once __DIR__ . '/providers/FakeProvider.php';
|
||||
|
||||
/** Baut eine PDO-Verbindung aus config.php auf. */
|
||||
function ai_backend_db(array $config): PDO
|
||||
{
|
||||
$db = $config['db'];
|
||||
$dsn = "mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4";
|
||||
return new PDO($dsn, $db['user'], $db['pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Authorization-Header — auf vielen Apache/PHP-FPM-Setups landet er NICHT in
|
||||
* $_SERVER['HTTP_AUTHORIZATION'] (Header wird von PHP-FPM standardmäßig verworfen), sondern nur
|
||||
* in $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] (nach der .htaccess-Weiterleitung, siehe dort) oder
|
||||
* ist nur über getallheaders() erreichbar. Alle drei Quellen abklappern, statt sich auf eine zu
|
||||
* verlassen — genau das war die Ursache für "Login klappt, danach sofort Session abgelaufen".
|
||||
*/
|
||||
function ai_backend_authorization_header(): string
|
||||
{
|
||||
if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
return $_SERVER['HTTP_AUTHORIZATION'];
|
||||
}
|
||||
if (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
|
||||
return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
|
||||
}
|
||||
if (function_exists('getallheaders')) {
|
||||
foreach (getallheaders() as $name => $value) {
|
||||
if (strcasecmp($name, 'Authorization') === 0) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer-Token aus dem Authorization-Header lesen, gegen tokens.token_hash prüfen
|
||||
* (SHA-256, der Klartext wird nie gespeichert) und den zugehörigen aktiven User zurückgeben.
|
||||
* Sendet bei fehlendem/ungültigem/abgelaufenem Token selbst eine 401-Antwort und beendet das Skript.
|
||||
*/
|
||||
function ai_backend_authenticate(PDO $pdo): array
|
||||
{
|
||||
$header = ai_backend_authorization_header();
|
||||
if (!preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
|
||||
ai_backend_fail(401, 'Kein gültiges Token übermittelt.');
|
||||
}
|
||||
$tokenHash = hash('sha256', $m[1]);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT u.* FROM users u
|
||||
JOIN tokens t ON t.user_id = u.id
|
||||
WHERE t.token_hash = ? AND t.expires_at > NOW() AND u.is_active = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$tokenHash]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user) {
|
||||
ai_backend_fail(401, 'Ungültiges oder abgelaufenes Token.');
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** Einheitliche Fehlerantwort als JSON, beendet danach das Skript. */
|
||||
function ai_backend_fail(int $httpStatus, string $message, array $details = []): never
|
||||
{
|
||||
http_response_code($httpStatus);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => $message] + $details, JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dekodiert eine JSON-Antwort des LLM. Modelle setzen trotz entsprechender Anweisung gelegentlich
|
||||
* Markdown-Codezäune oder einen kurzen Begleittext um das eigentliche JSON. Diese rein
|
||||
* syntaktischen Zusätze sollen eine ansonsten gültige Antwort nicht unbrauchbar machen.
|
||||
*/
|
||||
function ai_backend_decode_json_response(string $content): ?array
|
||||
{
|
||||
$content = trim($content, "\xEF\xBB\xBF \t\n\r\0\x0B");
|
||||
$candidates = [$content];
|
||||
|
||||
if (preg_match('/```(?:json)?\s*([\s\S]*?)\s*```/i', $content, $match) === 1) {
|
||||
$candidates[] = trim($match[1]);
|
||||
}
|
||||
|
||||
$firstBrace = strpos($content, '{');
|
||||
$lastBrace = strrpos($content, '}');
|
||||
if ($firstBrace !== false && $lastBrace !== false && $lastBrace >= $firstBrace) {
|
||||
$candidates[] = substr($content, $firstBrace, $lastBrace - $firstBrace + 1);
|
||||
}
|
||||
|
||||
foreach (array_unique($candidates) as $candidate) {
|
||||
$decoded = json_decode($candidate, true);
|
||||
if (is_array($decoded) && json_last_error() === JSON_ERROR_NONE) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
error_log(sprintf(
|
||||
'LLM JSON parse failed: %s; length=%d; sha256=%s',
|
||||
json_last_error_msg(),
|
||||
strlen($content),
|
||||
hash('sha256', $content)
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft das konfigurierte LLM (oder den FakeProvider für lokale Tests, siehe README.md) auf und
|
||||
* verrechnet die echten Token-Kosten gegen das Guthaben des Nutzers. Gemeinsame Logik für jeden
|
||||
* Endpunkt, der einen LLM-Call abrechnet (plan.php, explain.php) — die SELECT-FOR-UPDATE-
|
||||
* Absicherung gegen Race Conditions bei gleichzeitigen Anfragen desselben Nutzers soll nicht an
|
||||
* mehreren Stellen gepflegt werden müssen. Gibt bei Erfolg das Provider-Ergebnis unverändert
|
||||
* zurück (inkl. "content", das der Aufrufer je nach Endpunkt selbst auswertet).
|
||||
*/
|
||||
function ai_backend_call_and_charge(PDO $pdo, array $config, array $user, string $systemPrompt, string $userContent): array
|
||||
{
|
||||
$useFake = getenv('AI_BACKEND_FAKE_PROVIDER') === '1';
|
||||
if ($useFake) {
|
||||
$provider = new FakeProvider();
|
||||
$modelKey = 'fake';
|
||||
} else {
|
||||
$providerName = $config['llm_provider'];
|
||||
if ($providerName !== 'anthropic') {
|
||||
ai_backend_fail(500, "Provider '$providerName' ist nicht implementiert.");
|
||||
}
|
||||
$modelKey = $config['anthropic']['model'];
|
||||
$provider = new AnthropicProvider($config['anthropic']['api_key'], $modelKey);
|
||||
}
|
||||
|
||||
// AnthropicProvider erlaubt dem LLM-Aufruf per curl bis zu 180s (siehe dort) — PHPs eigenes
|
||||
// max_execution_time-Limit (auf vielen Hosts standardmäßig nur 30s) läuft davon unbeeinflusst
|
||||
// weiter und killt das Skript sonst mitten im curl_exec() mit einem unabfangbaren Fatal Error,
|
||||
// sobald ein Aufruf einmal länger braucht als das Hosting-Default. Das ergibt beim Client keine
|
||||
// lesbare Fehlermeldung (kein JSON-Body mehr), nur den pauschalen Fallback in
|
||||
// AiPlanningService.BuildRequestFailedExceptionAsync. Deshalb hier großzügig über die 180s der
|
||||
// Provider-Seite hinaus verlängern, statt das Limit stillschweigend greifen zu lassen.
|
||||
set_time_limit(200);
|
||||
|
||||
try {
|
||||
$result = $provider->sendMessage($systemPrompt, $userContent, $config['max_output_tokens']);
|
||||
} catch (RuntimeException $e) {
|
||||
ai_backend_fail(502, $e->getMessage());
|
||||
}
|
||||
|
||||
$defaultPricing = ['input' => 0, 'output' => 0, 'cache_write' => 0, 'cache_read' => 0];
|
||||
$pricing = $config['pricing'][$modelKey] ?? ($useFake ? $defaultPricing : null);
|
||||
if ($pricing === null) {
|
||||
ai_backend_fail(500, "Kein Preis für Modell '$modelKey' konfiguriert.");
|
||||
}
|
||||
$pricing += $defaultPricing; // fehlende cache_write/cache_read in älteren config.php-Einträgen -> 0
|
||||
|
||||
$cacheCreationTokens = $result['cacheCreationInputTokens'] ?? 0;
|
||||
$cacheReadTokens = $result['cacheReadInputTokens'] ?? 0;
|
||||
$cost = ($result['inputTokens'] / 1_000_000 * $pricing['input'])
|
||||
+ ($result['outputTokens'] / 1_000_000 * $pricing['output'])
|
||||
+ ($cacheCreationTokens / 1_000_000 * $pricing['cache_write'])
|
||||
+ ($cacheReadTokens / 1_000_000 * $pricing['cache_read']);
|
||||
|
||||
// Guthaben abziehen und Transaktion protokollieren — mit Zeilensperre, damit zwei gleichzeitige
|
||||
// Anfragen desselben Nutzers das Guthaben nicht versehentlich unter 0 drücken können.
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT balance_usd FROM users WHERE id = ? FOR UPDATE');
|
||||
$stmt->execute([$user['id']]);
|
||||
$currentBalance = (float) $stmt->fetchColumn();
|
||||
|
||||
if ($currentBalance - $cost < 0) {
|
||||
$pdo->rollBack();
|
||||
ai_backend_fail(402, 'Guthaben würde durch diese Anfrage negativ werden.');
|
||||
}
|
||||
|
||||
$newBalance = $currentBalance - $cost;
|
||||
$pdo->prepare('UPDATE users SET balance_usd = ? WHERE id = ?')->execute([$newBalance, $user['id']]);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO transactions
|
||||
(user_id, type, model, input_tokens, output_tokens,
|
||||
cache_creation_input_tokens, cache_read_input_tokens, cost_usd, balance_after)
|
||||
VALUES (?, "usage", ?, ?, ?, ?, ?, ?, ?)'
|
||||
)->execute([
|
||||
$user['id'], $modelKey, $result['inputTokens'], $result['outputTokens'],
|
||||
$cacheCreationTokens, $cacheReadTokens, $cost, $newBalance,
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$pdo = ai_backend_db($config);
|
||||
$user = ai_backend_authenticate($pdo);
|
||||
|
||||
if ((float) $user['balance_usd'] <= 0) {
|
||||
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($body) || !isset($body['unit'], $body['lesson'])) {
|
||||
ai_backend_fail(400, 'Ungültige Anfrage.');
|
||||
}
|
||||
|
||||
// Fester, eigener Systemprompt (4.5.21 "Schattenfeld") — anders als plan.php ändert dieser
|
||||
// Endpunkt nichts an der Planung, sondern liefert nur eine erklärende Einordnung dazu, damit die
|
||||
// Lehrkraft eine von der KI vorgeschlagene Stunde souverän halten kann, auch wenn sie nicht die
|
||||
// eigene Idee war. Bewusst als eigener Endpunkt statt als Zusatzfeld in jeder plan.php-Antwort:
|
||||
// die Erklärung wird nur auf Nachfrage abgerufen und damit auch nur dann abgerechnet.
|
||||
$systemPrompt = <<<PROMPT
|
||||
Du bist ein Assistent für die Unterrichtsplanung einer Lehrkraft an einer deutschen Schule. Du
|
||||
bekommst eine bereits vorgeschlagene oder geplante Unterrichtsstunde ("lesson", mit ihrem
|
||||
Verlaufsplan "phases") im Kontext ihrer Einheit ("unit"). Ändere NICHTS an der Planung — erkläre
|
||||
ausschließlich den didaktischen Hintergrund dazu, damit die Lehrkraft die Stunde souverän im
|
||||
Unterricht einsetzen kann, auch wenn sie nicht ihre eigene Idee war.
|
||||
|
||||
## Eingabeschema
|
||||
|
||||
{
|
||||
"unit": { "title": "...", "subjectName": "...", "gradeLevel": <Zahl>, "groupName": "..." , ...weitere Felder als Lesekontext },
|
||||
"lesson": {
|
||||
"topic": "<Thema>", "date": "<TT.MM.JJJJ oder null>",
|
||||
"phases": [
|
||||
{ "name": "<Phasenname>", "durationMinutes": <Zahl>, "activity": "<Tätigkeit>", "material": "<Material>", "shorthand": "<Kurzsymbol>" }
|
||||
],
|
||||
"homework": "<Hausaufgabe oder null>", "reflection": "<Reflexion oder null>",
|
||||
"planningIdeas": "<grobe Ideen der Lehrkraft vor der Feinplanung oder null, nur Lesekontext>"
|
||||
}
|
||||
}
|
||||
|
||||
## Antwortformat
|
||||
|
||||
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in genau diesem Schema:
|
||||
{ "explanation": "<Text>" }
|
||||
|
||||
Der Text sollte knapp, aber konkret sein (kein Roman) und dabei, soweit für diese Stunde relevant,
|
||||
folgende Aspekte abdecken:
|
||||
- Warum dieser Aufbau/diese Reihenfolge der Phasen (kurze didaktische Begründung)
|
||||
- Mögliche Stolpersteine oder typische Schülerfehler/-missverständnisse bei diesem Thema
|
||||
- Ansatzpunkte zur Differenzierung (schwächere/stärkere Schüler)
|
||||
Kurze Absätze oder eine kurze Liste, keine Überschriften nötig — die Lehrkraft soll das in ein bis
|
||||
zwei Minuten überfliegen können, nicht einen Aufsatz lesen.
|
||||
PROMPT;
|
||||
|
||||
$userContent = json_encode($body);
|
||||
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
|
||||
|
||||
$parsed = ai_backend_decode_json_response($result['content']);
|
||||
if (!is_array($parsed) || !isset($parsed['explanation'])) {
|
||||
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
|
||||
}
|
||||
|
||||
echo json_encode($parsed);
|
||||
@@ -1,90 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$pdo = ai_backend_db($config);
|
||||
$user = ai_backend_authenticate($pdo);
|
||||
|
||||
if ((float) $user['balance_usd'] <= 0) {
|
||||
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($body) || !isset($body['unit'], $body['lesson'])) {
|
||||
ai_backend_fail(400, 'Ungültige Anfrage.');
|
||||
}
|
||||
|
||||
// Eigener Endpunkt (Nutzerwunsch neben 4.2 "Anhänge je Stunde"), da inhaltlich unabhängig von
|
||||
// plan.php/explain.php: liefert einen Entwurf für eine Gefährdungsbeurteilung zu genau dieser
|
||||
// Stunde, zur Weiterbearbeitung im Gefährdungsbeurteilungs-Assistenten des Desktop-Clients.
|
||||
$systemPrompt = <<<PROMPT
|
||||
Du bist ein Assistent für die Unterrichtsvorbereitung einer Chemie-/Naturwissenschafts-Lehrkraft
|
||||
an einer deutschen Schule. Du bekommst eine geplante Unterrichtsstunde ("lesson", mit ihrem
|
||||
Verlaufsplan "phases") im Kontext ihrer Einheit ("unit"). Erkenne aus Thema, Tätigkeiten und
|
||||
Material der Stunde, welches Experiment durchgeführt wird, und formuliere dafür EINEN Entwurf
|
||||
einer schulüblichen Gefährdungsbeurteilung.
|
||||
|
||||
## WICHTIG — Grenzen dieser Aufgabe
|
||||
Dies ist NUR ein Entwurf zur Weiterbearbeitung durch die Lehrkraft, KEIN rechtsverbindliches
|
||||
Dokument — das macht die Lehrkraft der App bereits an dieser Stelle unmissverständlich klar,
|
||||
wiederhole diesen Hinweis NICHT im JSON selbst. Gib ausschließlich Angaben wieder, die dir aus
|
||||
allgemeinem Fachwissen zu den genannten Stoffen/Verfahren bekannt sind. Erfinde KEINE H-/P-Sätze
|
||||
oder Mengenangaben, wenn du dir nicht sicher bist — schreibe in diesem Fall einen Platzhalter wie
|
||||
"bitte gegen Sicherheitsdatenblatt prüfen" statt einer erfundenen Angabe. Sei bei der Einstufung
|
||||
Lehrerversuch/Schülerversuch/Demonstrationsversuch eher vorsichtig (im Zweifel Lehrerversuch statt
|
||||
Schülerversuch).
|
||||
|
||||
## Eingabeschema
|
||||
{
|
||||
"unit": { "title": "...", "subjectName": "...", "gradeLevel": <Zahl>, "groupName": "...", ...weitere Felder als Lesekontext },
|
||||
"lesson": {
|
||||
"topic": "<Thema>",
|
||||
"phases": [
|
||||
{ "name": "...", "activity": "...", "material": "...", "shorthand": "..." }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
## Antwortformat
|
||||
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in genau diesem Schema:
|
||||
{
|
||||
"experimentKind": "Lehrerversuch" | "Schuelerversuch" | "Demonstrationsversuch",
|
||||
"procedure": "<Kurzbeschreibung der Durchführung, 2-4 Sätze>",
|
||||
"substances": [
|
||||
{
|
||||
"name": "<Stoffname>",
|
||||
"amount": "<typische Einsatzmenge, z.B. \"ca. 2 g\">",
|
||||
"ghsPictograms": ["GHS05", "GHS02"],
|
||||
"hStatements": "<H-Sätze mit Kurztext, kommagetrennt, z.B. \"H314 - Verursacht schwere Verätzungen\">",
|
||||
"pStatements": "<P-Sätze, kommagetrennt>"
|
||||
}
|
||||
],
|
||||
"hazards": ["<identifizierte Gefährdung 1>", "..."],
|
||||
"protectiveMeasures": ["<Schutzmaßnahme 1>", "..."],
|
||||
"firstAid": "<Erste-Hilfe-Hinweise>",
|
||||
"disposal": "<Entsorgungshinweise>"
|
||||
}
|
||||
|
||||
Gültige Werte für "ghsPictograms": GHS01 (explosionsgefährlich), GHS02 (entzündbar), GHS03
|
||||
(brandfördernd), GHS04 (Gase unter Druck), GHS05 (ätzend), GHS06 (giftig), GHS07
|
||||
(gesundheitsschädlich/reizend), GHS08 (gesundheitsgefährdend), GHS09 (umweltgefährdend).
|
||||
|
||||
Halte alle Freitexte knapp und konkret (Stichpunkte statt Fließtext, wo sinnvoll). Nenne nur
|
||||
Stoffe, die laut Verlaufsplan tatsächlich verwendet werden. Wenn im Verlaufsplan kein erkennbares
|
||||
Experiment mit Gefahrstoffen vorkommt, liefere trotzdem das vollständige Schema mit plausiblen,
|
||||
aber vorsichtigen Angaben und einer leeren "substances"-Liste.
|
||||
PROMPT;
|
||||
|
||||
$userContent = json_encode($body);
|
||||
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
|
||||
|
||||
$parsed = ai_backend_decode_json_response($result['content']);
|
||||
if (!is_array($parsed) || !isset($parsed['procedure'])) {
|
||||
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
|
||||
}
|
||||
|
||||
echo json_encode($parsed);
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$pdo = ai_backend_db($config);
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$username = trim((string) ($body['username'] ?? ''));
|
||||
$password = (string) ($body['password'] ?? '');
|
||||
|
||||
if ($username === '' || $password === '') {
|
||||
ai_backend_fail(400, 'Benutzername und Passwort erforderlich.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? AND is_active = 1 LIMIT 1');
|
||||
$stmt->execute([$username]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
ai_backend_fail(401, 'Benutzername oder Passwort ist falsch.');
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$expiresAt = (new DateTimeImmutable('+30 days'))->format('Y-m-d H:i:s');
|
||||
|
||||
$stmt = $pdo->prepare('INSERT INTO tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)');
|
||||
$stmt->execute([$user['id'], $tokenHash, $expiresAt]);
|
||||
|
||||
echo json_encode(['token' => $token]);
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Migration für bereits deployte Installationen (schema.sql von vor dem Prompt-Caching-Nachtrag):
|
||||
-- fügt die beiden neuen Spalten nachträglich hinzu, ohne bestehende Daten anzufassen.
|
||||
-- Einmalig ausführen: mysql -u <user> -p <datenbankname> < migrations/2026-08-add-cache-tokens.sql
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN cache_creation_input_tokens INT NOT NULL DEFAULT 0 AFTER output_tokens,
|
||||
ADD COLUMN cache_read_input_tokens INT NOT NULL DEFAULT 0 AFTER cache_creation_input_tokens;
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$pdo = ai_backend_db($config);
|
||||
$user = ai_backend_authenticate($pdo);
|
||||
if ((float) $user['balance_usd'] <= 0) {
|
||||
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($body) || !isset($body['document']['pages']) || !is_array($body['candidates'] ?? null)) {
|
||||
ai_backend_fail(400, 'Dokument oder Kandidaten fehlen.');
|
||||
}
|
||||
if (count($body['candidates']) > 500) {
|
||||
ai_backend_fail(413, 'Zu viele Textkandidaten in einer Anfrage.');
|
||||
}
|
||||
|
||||
$systemPrompt = <<<'PROMPT'
|
||||
Du klassifizierst Textblöcke aus deutschen Schul- und Verwaltungsdokumenten für einen
|
||||
Vorlageneditor. Die Geometrie wurde deterministisch aus einem PDF extrahiert und darf von dir
|
||||
niemals geändert oder ergänzt werden. Du erhältst Seiten, Textblöcke und eine Liste von Kandidaten.
|
||||
|
||||
Für jeden Kandidaten:
|
||||
- vergib einen kurzen stabilen deutschen Placeholder-Namen nur aus Buchstaben und Ziffern,
|
||||
- wähle type ausschließlich aus text, multiline, date, number,
|
||||
- wähle confidence ausschließlich aus high, medium, low,
|
||||
- darfst du benachbarte, logisch zusammengehörige Zeilen gruppieren. Dann enthält blockIds alle
|
||||
Original-IDs der Gruppe. Jede übernommene ID muss exakt aus der Eingabe stammen.
|
||||
- typische Namen sind Anrede, Empfaenger, Adresse, PlzOrt, Datum, Betreff, Aktenzeichen, Brieftext.
|
||||
|
||||
Antworte AUSSCHLIESSLICH mit gültigem JSON in diesem Schema:
|
||||
{
|
||||
"classifications": [
|
||||
{
|
||||
"id": "<id eines Kandidaten>",
|
||||
"blockIds": ["<unveränderte Original-ID>"],
|
||||
"name": "<PlaceholderName>",
|
||||
"type": "text|multiline|date|number",
|
||||
"confidence": "high|medium|low"
|
||||
}
|
||||
]
|
||||
}
|
||||
PROMPT;
|
||||
|
||||
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt,
|
||||
json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$parsed = ai_backend_decode_json_response($result['content']);
|
||||
if (!is_array($parsed) || !is_array($parsed['classifications'] ?? null)) {
|
||||
ai_backend_fail(502, 'Die KI hat kein gültiges Klassifikations-JSON geliefert.');
|
||||
}
|
||||
|
||||
$knownIds = [];
|
||||
foreach ($body['document']['pages'] as $page) {
|
||||
foreach (($page['textBlocks'] ?? []) as $block) {
|
||||
if (isset($block['id'])) $knownIds[(string) $block['id']] = true;
|
||||
}
|
||||
}
|
||||
$candidateIds = array_fill_keys(array_map(fn($x) => (string) ($x['id'] ?? ''), $body['candidates']), true);
|
||||
$allowedTypes = ['text', 'multiline', 'date', 'number'];
|
||||
$allowedConfidence = ['high', 'medium', 'low'];
|
||||
foreach ($parsed['classifications'] as $classification) {
|
||||
if (!isset($candidateIds[(string) ($classification['id'] ?? '')])
|
||||
|| !in_array($classification['type'] ?? '', $allowedTypes, true)
|
||||
|| !in_array($classification['confidence'] ?? '', $allowedConfidence, true)
|
||||
|| !preg_match('/^[\pL\pN]+$/u', (string) ($classification['name'] ?? ''))
|
||||
|| !is_array($classification['blockIds'] ?? null)) {
|
||||
ai_backend_fail(502, 'Die KI-Klassifikation enthält ungültige Werte.');
|
||||
}
|
||||
foreach ($classification['blockIds'] as $blockId) {
|
||||
if (!isset($knownIds[(string) $blockId])) {
|
||||
ai_backend_fail(502, 'Die KI-Klassifikation referenziert einen unbekannten Textblock.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['classifications' => $parsed['classifications']], JSON_UNESCAPED_UNICODE);
|
||||
@@ -1,194 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$pdo = ai_backend_db($config);
|
||||
$user = ai_backend_authenticate($pdo);
|
||||
|
||||
if ((float) $user['balance_usd'] <= 0) {
|
||||
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($body) || !isset($body['unit'])) {
|
||||
ai_backend_fail(400, 'Ungültige Anfrage.');
|
||||
}
|
||||
|
||||
// Fester Basiskontext (4.5.13): erklärt Domäne, Felder und Konventionen einmal grundlegend, damit
|
||||
// die Lehrkraft das im freien "instruction"-Feld nicht bei jeder Anfrage wiederholen muss — dort
|
||||
// steht dann nur noch das fachlich Konkrete ("Baue zwei Stunden zu X mit steigendem Anspruch").
|
||||
$systemPrompt = <<<PROMPT
|
||||
Du bist ein Assistent für die Unterrichtsplanung einer Lehrkraft an einer deutschen Schule.
|
||||
Du bekommst eine Unterrichtseinheit ("unit", mit ihren bisherigen Unterrichtsstunden "lessons")
|
||||
sowie eine freie Anweisung der Lehrkraft ("instruction") und sollst neue oder geänderte Stunden
|
||||
für diese Einheit vorschlagen.
|
||||
|
||||
## Eingabeschema
|
||||
|
||||
Die Eingabe hat exakt diese Struktur:
|
||||
{
|
||||
"instruction": "<freie Anweisung der Lehrkraft, kann auch leer sein>",
|
||||
"allowModifyingExistingLessons": <true oder false, siehe Abschnitt "Umfang dieser Anfrage">,
|
||||
"focusLessonId": "<GUID einer einzelnen Stunde oder null, siehe Abschnitt "Umfang dieser Anfrage">,
|
||||
"unit": {
|
||||
"id": "<GUID der Einheit>",
|
||||
"title": "<Titel der Einheit>",
|
||||
"startDate": "<TT.MM.JJJJ oder null>",
|
||||
"endDate": "<TT.MM.JJJJ oder null>",
|
||||
"competencies": ["<Kompetenz-Codes, die der Einheit bereits zugeordnet sind>"],
|
||||
"notes": "<Notizen zur Einheit oder null>",
|
||||
"subjectName": "<Fach>",
|
||||
"gradeLevel": <Klassenstufe als Zahl>,
|
||||
"groupName": "<Name der Lerngruppe>",
|
||||
"competencyCatalog": [
|
||||
{ "name": "<Kompetenzbereich>", "items": [{ "code": "<Code>", "description": "<Beschreibung>" }] }
|
||||
],
|
||||
"alternativePathCatalog": [
|
||||
{ "id": "<GUID>", "name": "<Name des alternativen Ablaufs>" }
|
||||
],
|
||||
"lessons": [
|
||||
{
|
||||
"id": "<GUID der bestehenden Stunde>",
|
||||
"date": "<TT.MM.JJJJ oder null>",
|
||||
"lessonNumber": <Zahl oder null>,
|
||||
"topic": "<Thema>",
|
||||
"startTime": "<HH:mm oder null>",
|
||||
"phases": [
|
||||
{
|
||||
"name": "<Phasenname>", "durationMinutes": <Zahl>, "activity": "<Tätigkeit>",
|
||||
"material": "<Material>", "shorthand": "<Kurzsymbol>",
|
||||
"alternativePathName": "<Name aus alternativePathCatalog oder null>"
|
||||
}
|
||||
],
|
||||
"homework": "<Hausaufgabe oder null>",
|
||||
"reflection": "<Reflexion oder null>",
|
||||
"planningIdeas": "<Grobe Ideen/Rohentwurf der Lehrkraft vor der Feinplanung oder null>"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Alle Felder auf Einheiten-Ebene ("unit.id"/"title"/"startDate"/"endDate"/"competencies"/"notes"/
|
||||
"subjectName"/"gradeLevel"/"groupName"/"competencyCatalog"/"alternativePathCatalog") sind reiner
|
||||
Lesekontext. Die Einheit selbst wird nicht verändert — es gibt in deiner Antwort kein Feld dafür.
|
||||
Ausschließlich "unit.lessons[]" ist das, was du vorschlägst/änderst und in der Antwort zurückgibst.
|
||||
|
||||
## Umfang dieser Anfrage
|
||||
|
||||
Das Feld "allowModifyingExistingLessons" in der Eingabe legt fest, was du vorschlagen darfst:
|
||||
- true: Du darfst sowohl neue Stunden vorschlagen ("id": null) als auch bestehende Stunden
|
||||
inhaltlich ändern (dafür deren "id" aus der Eingabe exakt übernehmen).
|
||||
- false: Du darfst AUSSCHLIESSLICH neue Stunden vorschlagen ("id": null). Ändere KEINE bestehende
|
||||
Stunde inhaltlich — gib niemals die "id" einer bereits vorhandenen Stunde zurück, auch nicht
|
||||
unverändert. Falls die Anweisung der Lehrkraft eine Änderung an einer bestehenden Stunde
|
||||
verlangt, die dadurch nicht möglich ist, erkläre das kurz im "summary"-Feld und schlage
|
||||
stattdessen sinnvolle neue Stunden vor.
|
||||
|
||||
Ist "focusLessonId" gesetzt (Anfrage aus dem Editor einer einzelnen Stunde heraus, nicht aus der
|
||||
Einheiten-Übersicht): Bearbeite AUSSCHLIESSLICH die Stunde mit genau dieser Id gemäß der
|
||||
Anweisung. Schlage KEINE neuen Stunden vor und ändere KEINE andere bestehende Stunde, auch wenn
|
||||
"allowModifyingExistingLessons" true ist — die Lehrkraft sieht in diesem Fall nur diese eine
|
||||
Stunde zur Prüfung, alles andere würde ihr gar nicht angezeigt.
|
||||
|
||||
## Fachlicher Kontext und Konventionen
|
||||
|
||||
- Richte Anspruch, Wortwahl und Methodik nach "subjectName"/"gradeLevel"/"groupName" aus.
|
||||
- "competencyCatalog" dient nur zur fachlichen Einordnung — das Schema hat kein Feld, um
|
||||
einzelnen Stunden Kompetenzen zuzuordnen, erfinde daher kein solches Feld in der Antwort.
|
||||
- Eine Stunde entspricht einer Unterrichtsstunde à i.d.R. 45 Minuten (Doppelstunden als zwei
|
||||
Lessons oder als eine mit entsprechend höherer Phasen-Gesamtdauer, je nachdem wie es in den
|
||||
bereits vorhandenen Stunden dieser Einheit gehandhabt wird).
|
||||
- Eine Phase ("phases[]") ist eine Zeile im tabellarischen Stundenverlaufsplan, wie in deutschen
|
||||
Schulen üblich: typische Phasennamen sind z.B. Einstieg, Erarbeitung, Übung, Sicherung,
|
||||
Reflexion, Vertiefung — orientiere dich an bereits in der Einheit verwendeten Phasennamen,
|
||||
wenn vorhanden, statt eigene Konventionen einzuführen.
|
||||
- "shorthand" je Phase ist ein kurzes Sozialform-/Medienkürzel, wie es in deutschen
|
||||
Stundenverlaufsplänen üblich ist (z.B. "L" Lehrervortrag, "S" Schülerbeitrag, "EA"
|
||||
Einzelarbeit, "PA" Partnerarbeit, "GA" Gruppenarbeit, "UG" Unterrichtsgespräch, "Tb" Tafelbild,
|
||||
"AB" Arbeitsblatt) — kein Fließtext.
|
||||
- "durationMinutes" je Phase sollte in Summe zur für die Stunde realistischen Zeit passen
|
||||
(i.d.R. rund 45 Minuten je Einzelstunde, abzüglich organisatorischer Zeit).
|
||||
- "planningIdeas" einer Stunde enthält oft schon vor der Feinplanung festgehaltene grobe Ideen der
|
||||
Lehrkraft (Stichpunkte, erste Gedanken, mögliche Aufhänger) — nutze das, wo vorhanden, als starken
|
||||
Hinweis für Thema/Verlaufsplan dieser Stunde, ohne jedes Stichwort wörtlich übernehmen zu müssen.
|
||||
Gib das Feld in deiner Antwort unverändert zurück, außer die Anweisung der Lehrkraft verlangt
|
||||
ausdrücklich eine Überarbeitung dieser Ideen selbst.
|
||||
- "materialSuggestion" je Phase (nur in deiner Antwort, nicht in der Eingabe): ein kurzer Vorschlag
|
||||
(1-2 Sätze), WAS ein zu dieser Phase passendes Medium/Material konkret zeigen oder enthalten
|
||||
sollte (z.B. Aufbau eines Tafelbilds, Inhalt eines Arbeitsblatts) — nicht nur "ein Tafelbild
|
||||
wäre hilfreich", sondern was darauf stehen sollte. Nur setzen, wenn ein Medium über das bereits
|
||||
in "material"/"shorthand" Genannte hinaus wirklich einen Mehrwert hätte, sonst weglassen (null).
|
||||
Die Lehrkraft erzeugt das Medium separat selbst mit diesem Vorschlag als Grundlage — plane hier
|
||||
keine Umsetzungsdetails wie Layout oder Werkzeug.
|
||||
|
||||
## Antwortformat
|
||||
|
||||
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in genau diesem Schema:
|
||||
{
|
||||
"lessons": [
|
||||
{
|
||||
"id": "<GUID der bestehenden Stunde ODER null für eine neue Stunde>",
|
||||
"date": "<TT.MM.JJJJ oder null>",
|
||||
"lessonNumber": <Zahl oder null>,
|
||||
"topic": "<Thema>",
|
||||
"startTime": "<HH:mm oder null>",
|
||||
"phases": [
|
||||
{
|
||||
"name": "<Phasenname>",
|
||||
"durationMinutes": <Zahl>,
|
||||
"activity": "<Tätigkeit>",
|
||||
"material": "<Material>",
|
||||
"shorthand": "<Kurzsymbol>",
|
||||
"alternativePathName": "<Name aus dem übergebenen Katalog oder null für den Hauptweg>",
|
||||
"materialSuggestion": "<konkreter Medienvorschlag oder null, siehe oben>"
|
||||
}
|
||||
],
|
||||
"homework": "<Hausaufgabe oder null>",
|
||||
"reflection": "<Reflexion oder null>",
|
||||
"planningIdeas": "<unverändert aus der Eingabe übernommen, siehe oben, oder null>"
|
||||
}
|
||||
],
|
||||
"summary": "<kurze menschenlesbare Zusammenfassung, was du getan hast>"
|
||||
}
|
||||
|
||||
WICHTIG: Um eine bestehende Stunde zu ändern, gib exakt deren "id" aus der Eingabe zurück. Für
|
||||
eine neu vorgeschlagene Stunde setze "id" auf null. Erfinde niemals eine Id, die nicht in der
|
||||
Eingabe stand. Nutze für "alternativePathName" nur Namen aus dem mitgelieferten Katalog. Wenn die
|
||||
Anweisung der Lehrkraft unklar oder zu knapp ist, triff eine plausible, fachlich begründbare
|
||||
Annahme, statt nachzufragen (eine Rückfrage ist über diese Schnittstelle nicht möglich) — beschreibe
|
||||
deine Annahme kurz im "summary"-Feld.
|
||||
PROMPT;
|
||||
|
||||
$userContent = json_encode($body);
|
||||
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
|
||||
|
||||
// Erst NACH der Abrechnung validieren: die Token wurden real verbraucht, das wird auch dann
|
||||
// verrechnet, wenn die KI kein valides JSON geliefert hat (siehe Planungsdokument).
|
||||
if (($result['stopReason'] ?? null) === 'max_tokens') {
|
||||
ai_backend_fail(502,
|
||||
'Die KI-Antwort wurde am Ausgabelimit von ' . $config['max_output_tokens'] .
|
||||
' Tokens abgeschnitten. Bitte den Umfang der Anfrage verkleinern oder das Serverlimit erhöhen.', [
|
||||
'errorCode' => 'output_limit_reached',
|
||||
'rawResponse' => $result['content'],
|
||||
]);
|
||||
}
|
||||
|
||||
$parsed = ai_backend_decode_json_response($result['content']);
|
||||
if (!is_array($parsed) || !isset($parsed['lessons']) || !is_array($parsed['lessons'])) {
|
||||
// Die bezahlte Modellantwort nicht wegwerfen: Der Desktop-Client kann sie in einem
|
||||
// Rettungsdialog vollständig anzeigen und die Lehrkraft daraus gültiges JSON markieren bzw.
|
||||
// von Hand korrigieren lassen. Nur dieser Planungsendpunkt bietet einen manuellen Import an.
|
||||
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.', [
|
||||
'rawResponse' => $result['content'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Auch bei syntaktisch gültigem JSON kann erst der streng typisierte Desktop-Client einen
|
||||
// Feldfehler entdecken (z.B. ein unlesbares Datum). Deshalb reist die Originalantwort bis zum
|
||||
// Client mit; dort wird sie nach erfolgreicher Verarbeitung sofort wieder verworfen.
|
||||
$parsed['rawResponse'] = $result['content'];
|
||||
echo json_encode($parsed);
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/ProviderInterface.php';
|
||||
|
||||
/**
|
||||
* Ruft die Anthropic Messages API direkt per curl auf — bewusst ohne SDK-Abhängigkeit.
|
||||
*
|
||||
* Der Systemprompt wird als eigener, mit "cache_control" markierter Content-Block gesendet
|
||||
* (Prompt Caching, siehe TODO 4.5.9-Nachtrag) statt als einfacher String — er ist über alle
|
||||
* Anfragen hinweg identisch (siehe plan.php), also ein idealer Kandidat: wiederholte Aufrufe
|
||||
* innerhalb der Cache-TTL (Anthropic-Standard: 5 Minuten) zahlen dafür nur den stark reduzierten
|
||||
* "Cache-Read"-Preis statt des vollen Input-Preises. Der erste Aufruf nach TTL-Ablauf zahlt einen
|
||||
* kleinen Aufpreis fürs Neuschreiben des Caches ("cache_creation_input_tokens") — siehe
|
||||
* plan.php für die Abrechnung beider Fälle.
|
||||
*/
|
||||
class AnthropicProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(private string $apiKey, private string $model) {}
|
||||
|
||||
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array
|
||||
{
|
||||
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'content-type: application/json',
|
||||
'x-api-key: ' . $this->apiKey,
|
||||
'anthropic-version: 2023-06-01',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode([
|
||||
'model' => $this->model,
|
||||
'max_tokens' => $maxTokens,
|
||||
'system' => [
|
||||
['type' => 'text', 'text' => $systemPrompt, 'cache_control' => ['type' => 'ephemeral']],
|
||||
],
|
||||
'messages' => [['role' => 'user', 'content' => $userContent]],
|
||||
]),
|
||||
// Umfangreiche Einheitenplanungen können bei großen Antworten länger dauern. Der
|
||||
// Desktop-Client wartet etwas länger (210 s), damit immer dieser Server mit einer
|
||||
// verständlichen Fehlermeldung antworten kann, statt dass zuerst der Client abbricht.
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_TIMEOUT => 180,
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException("Anthropic-Anfrage fehlgeschlagen: $curlError");
|
||||
}
|
||||
if ($httpCode >= 400) {
|
||||
throw new RuntimeException("Anthropic-API-Fehler (HTTP $httpCode): " . substr((string) $raw, 0, 500));
|
||||
}
|
||||
|
||||
$data = json_decode((string) $raw, true);
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('Anthropic-Antwort konnte nicht als JSON gelesen werden.');
|
||||
}
|
||||
if (($data['stop_reason'] ?? null) === 'refusal') {
|
||||
throw new RuntimeException('Die KI hat die Anfrage abgelehnt.');
|
||||
}
|
||||
|
||||
$text = '';
|
||||
foreach (($data['content'] ?? []) as $block) {
|
||||
if (($block['type'] ?? null) === 'text') {
|
||||
$text .= $block['text'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => $text,
|
||||
// Insbesondere "max_tokens" darf nicht verloren gehen: Dann ist der Text zwar eine
|
||||
// technisch erfolgreiche API-Antwort, das JSON aber zwangsläufig am Ende abgeschnitten.
|
||||
'stopReason' => $data['stop_reason'] ?? null,
|
||||
'inputTokens' => (int) ($data['usage']['input_tokens'] ?? 0),
|
||||
'outputTokens' => (int) ($data['usage']['output_tokens'] ?? 0),
|
||||
'cacheCreationInputTokens' => (int) ($data['usage']['cache_creation_input_tokens'] ?? 0),
|
||||
'cacheReadInputTokens' => (int) ($data['usage']['cache_read_input_tokens'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/ProviderInterface.php';
|
||||
|
||||
/**
|
||||
* Für lokale Smoke-Tests ohne echten API-Key (siehe README.md) — liefert eine feste,
|
||||
* valide AiPlanningResponse-JSON zurück statt einen echten LLM-Aufruf zu machen. Niemals als
|
||||
* Standard-Provider in config.php eintragen, nur über eine explizite lokale Umgebungsvariable
|
||||
* (siehe plan.php) für Entwicklungszwecke aktivieren.
|
||||
*/
|
||||
class FakeProvider implements ProviderInterface
|
||||
{
|
||||
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array
|
||||
{
|
||||
if (str_contains($systemPrompt, 'Placeholder-Namen')) {
|
||||
$input = json_decode($userContent, true);
|
||||
$classifications = array_map(static fn(array $candidate): array => [
|
||||
'id' => $candidate['id'],
|
||||
'blockIds' => $candidate['blockIds'] ?? [$candidate['id']],
|
||||
'name' => $candidate['suggestedName'] ?? 'Feld',
|
||||
'type' => $candidate['type'] ?? 'text',
|
||||
'confidence' => $candidate['confidence'] ?? 'low',
|
||||
], $input['candidates'] ?? []);
|
||||
return [
|
||||
'content' => json_encode(['classifications' => $classifications]),
|
||||
'inputTokens' => 42, 'outputTokens' => 17,
|
||||
'cacheCreationInputTokens' => 0, 'cacheReadInputTokens' => 0,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'content' => json_encode([
|
||||
'lessons' => [
|
||||
[
|
||||
'id' => null,
|
||||
'date' => null,
|
||||
'lessonNumber' => null,
|
||||
'topic' => 'Fake-Vorschlag zum Testen',
|
||||
'startTime' => null,
|
||||
'phases' => [],
|
||||
'homework' => null,
|
||||
'reflection' => null,
|
||||
],
|
||||
],
|
||||
'summary' => 'Antwort des FakeProvider (kein echter KI-Aufruf).',
|
||||
]),
|
||||
'inputTokens' => 42,
|
||||
'outputTokens' => 17,
|
||||
'cacheCreationInputTokens' => 0,
|
||||
'cacheReadInputTokens' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
interface ProviderInterface
|
||||
{
|
||||
/**
|
||||
* cacheCreationInputTokens/cacheReadInputTokens sind für Provider ohne Prompt-Caching-
|
||||
* Unterstützung einfach immer 0 (siehe FakeProvider) — plan.php rechnet damit trotzdem
|
||||
* korrekt, ohne je nach Provider zu unterscheiden.
|
||||
*
|
||||
* @return array{content: string, inputTokens: int, outputTokens: int,
|
||||
* cacheCreationInputTokens: int, cacheReadInputTokens: int}
|
||||
*/
|
||||
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
-- KI-Backend (TODO 4.5.9) — MySQL/MariaDB-Schema.
|
||||
-- Multi-user-fähig von Anfang an, auch wenn zunächst nur ein Nutzer angelegt wird.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL, -- password_hash()-Ausgabe (bcrypt/argon2)
|
||||
balance_usd DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL, -- SHA-256 des Tokens; der Klartext wird nie gespeichert
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_token_hash (token_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
type ENUM('usage', 'topup') NOT NULL,
|
||||
model VARCHAR(64) NULL, -- z.B. 'claude-sonnet-5'; NULL bei topup
|
||||
input_tokens INT NULL,
|
||||
output_tokens INT NULL,
|
||||
-- Prompt Caching (Nachtrag): der stark reduziert abgerechnete "Cache-Read"-Anteil bzw. der
|
||||
-- etwas teurere "Cache-Write"-Anteil beim ersten Aufruf nach TTL-Ablauf. Für Requests ohne
|
||||
-- Caching (z.B. FakeProvider) immer 0, nicht NULL — vereinfacht Summenbildung in Auswertungen.
|
||||
cache_creation_input_tokens INT NOT NULL DEFAULT 0,
|
||||
cache_read_input_tokens INT NOT NULL DEFAULT 0,
|
||||
cost_usd DECIMAL(10,6) NOT NULL,
|
||||
balance_after DECIMAL(10,4) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_user_created (user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// CLI-Helfer zum Anlegen des ersten Nutzers (oder weiterer Lehrer später), siehe README.md.
|
||||
// Aufruf: php scripts/create-user.php <benutzername> <passwort> [startguthaben_usd]
|
||||
|
||||
require_once __DIR__ . '/../db.php';
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "Nur über die Kommandozeile ausführen.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
[, $username, $password, $startBalance] = array_pad($argv, 4, null);
|
||||
if ($username === null || $password === null) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/create-user.php <benutzername> <passwort> [startguthaben_usd]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$config = require __DIR__ . '/../config.php';
|
||||
$pdo = ai_backend_db($config);
|
||||
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$balance = $startBalance !== null ? (float) $startBalance : 0.0;
|
||||
|
||||
$stmt = $pdo->prepare('INSERT INTO users (username, password_hash, balance_usd) VALUES (?, ?, ?)');
|
||||
$stmt->execute([$username, $hash, $balance]);
|
||||
|
||||
echo "Nutzer '$username' angelegt, Startguthaben: " . number_format($balance, 2) . " USD.\n";
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Web-erreichbare Alternative zu scripts/create-user.php, für Hosting ohne PHP-CLI/SSH-Zugriff.
|
||||
// Durch ein Geheimnis aus config.php geschützt (setup_secret) — WICHTIG: diese Datei nach dem
|
||||
// Anlegen der benötigten Nutzer wieder vom Server löschen, siehe README.md.
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$secret = $config['setup_secret'] ?? null;
|
||||
|
||||
if (!is_string($secret) || $secret === '' || $secret === 'CHANGE_ME') {
|
||||
ai_backend_fail(500, 'setup_secret ist in config.php nicht (sicher) gesetzt.');
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
|
||||
if (!hash_equals($secret, (string) ($body['secret'] ?? ''))) {
|
||||
ai_backend_fail(403, 'Falsches oder fehlendes Geheimnis.');
|
||||
}
|
||||
|
||||
$username = trim((string) ($body['username'] ?? ''));
|
||||
$password = (string) ($body['password'] ?? '');
|
||||
$balance = isset($body['balanceUsd']) ? (float) $body['balanceUsd'] : 0.0;
|
||||
|
||||
if ($username === '' || $password === '') {
|
||||
ai_backend_fail(400, 'username und password erforderlich.');
|
||||
}
|
||||
|
||||
$pdo = ai_backend_db($config);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
// Erneuter Aufruf mit demselben Benutzernamen aktualisiert Passwort/Guthaben, statt einen Fehler
|
||||
// zu werfen — praktisch, falls beim ersten Versuch z.B. das Guthaben vergessen wurde.
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO users (username, password_hash, balance_usd) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), balance_usd = VALUES(balance_usd)'
|
||||
);
|
||||
$stmt->execute([$username, $hash, $balance]);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'username' => $username,
|
||||
'balanceUsd' => $balance,
|
||||
'hinweis' => 'Nutzer angelegt/aktualisiert. Diese Datei (setup-user.php) jetzt vom Server löschen.',
|
||||
]);
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$pdo = ai_backend_db($config);
|
||||
$user = ai_backend_authenticate($pdo);
|
||||
|
||||
echo json_encode(['balanceUsd' => (float) $user['balance_usd']]);
|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Lokale Datenbank aus der RiSU-Stoffliste ("Richtlinie Sicheres Experimentieren im Unterricht"),
|
||||
* vom Nutzer als offizielles PDF bereitgestellt (stoffliste.pdf, 123 Seiten, ~1800 Stoffe) und
|
||||
* einmalig in data/stoffliste.json umgewandelt. Ersetzt die vorher versuchten GESTIS-/KI-Websuche-
|
||||
* Ansätze vollständig (siehe TODO.md-Nachtrag zu 4.2): GESTIS selbst hatte weder zuverlässig
|
||||
* abrufbare noch schulspezifische Tätigkeitsbeschränkungs-Angaben, diese Stoffliste ist dagegen
|
||||
* gezielt für den Schuleinsatz zusammengestellt und enthält die Tätigkeitsbeschränkung (Freigabe/
|
||||
* Verbot/Altersgrenzen/Ersatzstoffprüfung/Lehrkraft-Vorbehalt) direkt als eigene Spalte — die
|
||||
* eigentlich entscheidende Angabe für eine schulische Gefährdungsbeurteilung.
|
||||
*
|
||||
* Reiner lokaler Datenbank-Lookup, kein KI-Aufruf mehr nötig (Nutzerwunsch: "wir brauchen die KI
|
||||
* gar nicht mehr im Backend. Es reicht die reine Datenbank.").
|
||||
*
|
||||
* data/stoffliste.json speichert bewusst nur die rohen Tätigkeitsbeschränkungs-CODES, nicht die
|
||||
* übersetzten Texte — die Übersetzung passiert erst hier, zur Abfragezeit (siehe
|
||||
* STOFFLISTE_RESTRICTION_LEGEND). So reicht bei einer künftigen Korrektur der Legende (wie schon
|
||||
* einmal nötig — die erste Fassung war falsch) eine Änderung an einer einzigen Stelle, statt die
|
||||
* komplette ~1800 Stoffe umfassende Datenbank neu erzeugen zu müssen.
|
||||
*/
|
||||
|
||||
const STOFFLISTE_PATH = __DIR__ . '/data/stoffliste.json';
|
||||
|
||||
/** Vom Nutzer bestätigte Legende (steht in keiner Form im PDF selbst). */
|
||||
const STOFFLISTE_RESTRICTION_LEGEND = [
|
||||
'+' => 'Generelle Erlaubnis',
|
||||
'X' => 'Generelles Tätigkeitsverbot an Schulen',
|
||||
'L+' => 'Tätigkeitsverbot für Lehrkräfte, Ausnahme siehe RiSU (I – 3.5)',
|
||||
'S' => 'Tätigkeitsverbot für Schülerinnen und Schüler',
|
||||
'S4K' => 'Tätigkeitsverbot für Schülerinnen und Schüler bis einschließlich Jahrgangsstufe 4',
|
||||
'S9K' => 'Tätigkeitsverbot für Schülerinnen und Schüler bis einschließlich Jahrgangsstufe 9',
|
||||
'W' => 'Tätigkeitsverbot für werdende oder stillende Mütter',
|
||||
'ESP' => 'Gesonderte Ersatzstoffprüfung notwendig',
|
||||
];
|
||||
|
||||
function stoffliste_normalize(string $value): string
|
||||
{
|
||||
return trim(mb_strtolower($value));
|
||||
}
|
||||
|
||||
/** Übersetzt Tätigkeitsbeschränkungs-Codes in lesbare Texte — unbekannte Codes werden
|
||||
* unverändert durchgereicht (Fail-Open: keinen Hinweis stillschweigend verschwinden lassen,
|
||||
* falls die Liste um einen neuen Code erweitert wird, den diese Legende noch nicht kennt). */
|
||||
function stoffliste_restriction_texts(array $codes): array
|
||||
{
|
||||
return array_map(
|
||||
fn(string $code) => STOFFLISTE_RESTRICTION_LEGEND[$code] ?? $code,
|
||||
$codes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* name: string, cas: string, relatedCas: list<string>, zvg: string, signalWord: string,
|
||||
* ghsPictograms: list<string>, hStatements: list<string>, pStatements: list<string>,
|
||||
* hazardCategories: list<string>, activityRestrictionCodes: list<string>
|
||||
* }>
|
||||
*/
|
||||
function stoffliste_load(): array
|
||||
{
|
||||
static $data = null;
|
||||
if ($data !== null) {
|
||||
return $data;
|
||||
}
|
||||
$raw = @file_get_contents(STOFFLISTE_PATH);
|
||||
$decoded = $raw !== false ? json_decode($raw, true) : null;
|
||||
$data = is_array($decoded) ? $decoded : [];
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht einen Stoff exakt (normalisierter Name oder CAS-Nummer, auch gegen "verwandte"
|
||||
* CAS-Nummern bei Stoffen mit mehreren erfassten CAS-Nummern) — bewusst kein Fuzzy-/Teilstring-
|
||||
* Match, ein falscher Treffer wäre hier schlimmer als gar keiner (die Antwort bezöge sich sonst
|
||||
* unbemerkt auf einen falschen Stoff samt dessen Tätigkeitsbeschränkung). Gibt null zurück, wenn
|
||||
* der Stoff nicht in der Liste steht.
|
||||
*/
|
||||
function stoffliste_lookup(string $query): ?array
|
||||
{
|
||||
$normalizedQuery = stoffliste_normalize($query);
|
||||
$data = stoffliste_load();
|
||||
|
||||
foreach ($data as $entry) {
|
||||
if (stoffliste_normalize((string) $entry['name']) === $normalizedQuery) {
|
||||
return $entry;
|
||||
}
|
||||
}
|
||||
foreach ($data as $entry) {
|
||||
if ($entry['cas'] !== '' && stoffliste_normalize((string) $entry['cas']) === $normalizedQuery) {
|
||||
return $entry;
|
||||
}
|
||||
foreach (($entry['relatedCas'] ?? []) as $related) {
|
||||
if (stoffliste_normalize((string) $related) === $normalizedQuery) {
|
||||
return $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user