Schülerdokumentation: Einträge, Fehlzeitenbilanz, Datenschutz (Kapitel 5)

Dokumentationseinträge mit Typwahl (Gespräch, Vorkommnis, Förderplan,
Fehlzeit, Elternanruf, Elternbrief), vertrauliche Einträge nur nach
Bestätigung sichtbar, weiche Löschung mit Nachvollziehbarkeit. Fehlzeiten
als Auswertung des bestehenden Anwesenheits-Trackings statt zweiter
Erfassung, mit Schwellenwert-Warnung im Schülerdetail und Dashboard.
Förderplan-Wiedervorlage als Dashboard-Karte. Datenschutz: Löschfristen
mit manueller Bereinigung und DSGVO-Art.-15-Datenauskunft als Export.

Auf Nutzer-Feedback hin ergänzt: Elternanruf mit begleitendem
Gesprächsprotokoll-Dialog (Punkte abhaken, Eindrücke festhalten),
Elternbrief mit Versand-/Rückmeldungs-Tracking, Datei-Anhänge über
LiteDBs Dateispeicher, frei vergebbare Labels zur Nachverfolgung mit
Dringlichkeits-Farbcodierung, sowie eine sichtbare Farblegende für das
bestehende Notenentwicklungs-Diagramm. Dabei einen Absturz behoben:
leere Textfelder lieferten über das Binding null statt "", was beim
Speichern eine NullReferenceException auslöste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 17:49:03 +02:00
co-authored by Claude Sonnet 5
parent 629b8d1bf0
commit d987b93315
36 changed files with 2395 additions and 62 deletions
@@ -0,0 +1,41 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public record AttendanceBalance(
int TotalChecked, int Present, int Excused, int Unexcused,
int SchoolEvent, int ExcusePending, double AbsenceRatePercent)
{
public bool ExceedsThreshold => AbsenceRatePercent > AttendanceBalanceService.WarningThresholdPercent;
}
/// <summary>
/// Fehlzeitenbilanz (5.2.2/5.2.3) — reine Auswertung der bereits im Mitarbeit-Feature
/// erfassten <see cref="AttendanceStatus"/>-Werte, keine zweite Erfassung.
/// </summary>
public class AttendanceBalanceService
{
public const double WarningThresholdPercent = 20.0;
public AttendanceBalance Calculate(IEnumerable<(DateOnly Date, AttendanceStatus? Status)> entries,
DateOnly from, DateOnly to)
{
var relevant = entries
.Where(e => e.Status.HasValue && e.Date >= from && e.Date <= to)
.Select(e => e.Status!.Value)
.ToList();
var present = relevant.Count(s => s == AttendanceStatus.Present);
var excused = relevant.Count(s => s == AttendanceStatus.Excused);
var unexcused = relevant.Count(s => s is AttendanceStatus.Unexcused or AttendanceStatus.Truant);
var schoolEvent = relevant.Count(s => s == AttendanceStatus.OtherSchoolEvent);
var pending = relevant.Count(s => s == AttendanceStatus.ExcusePending);
var total = relevant.Count;
// Schulisch veranlasste Abwesenheit (Exkursion o.ä.) zählt nicht als Fehlzeit des Schülers.
var absenceCount = excused + unexcused + pending;
var rate = total == 0 ? 0.0 : Math.Round(100.0 * absenceCount / total, 1);
return new AttendanceBalance(total, present, excused, unexcused, schoolEvent, pending, rate);
}
}
@@ -0,0 +1,56 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Core.Services;
/// <summary>
/// Datenauskunft für einen Schüler (5.4.3, Art. 15 DSGVO) — sammelt alle über die Person
/// gespeicherten Daten aus den einzelnen Repositories zu einem Export.
///
/// Enthält bewusst auch als vertraulich markierte Dokumentationseinträge: das
/// Vertraulich-Kennzeichen in dieser App blendet Inhalte in der laufenden Ansicht aus,
/// ist aber keine rechtliche Ausnahme vom Auskunftsanspruch der betroffenen Person selbst.
/// Ob im Einzelfall doch eine Ausnahme greift (z.B. schutzwürdige Belange Dritter nach
/// Landes-Schulrecht), muss die verantwortliche Lehrkraft/Schule selbst prüfen.
/// </summary>
public class PersonalDataExportService(
IStudentRepository students,
IGroupRepository groups,
IGroupMembershipRepository memberships,
IGradeRepository grades,
IExamResultRepository examResults,
IParticipationRepository participation,
IDocumentationRepository documentation)
{
public string ExportAsJson(Guid studentId)
{
var student = students.GetById(studentId)
?? throw new InvalidOperationException("Schüler nicht gefunden.");
var studentMemberships = memberships.GetByStudent(studentId);
var studentGroups = studentMemberships
.Select(m => groups.GetById(m.GroupId))
.Where(g => g is not null)
.ToList();
var dto = new
{
ExportedAt = DateTime.UtcNow,
Student = student,
Gruppenzuordnungen = studentMemberships,
Noten = studentGroups.SelectMany(g => grades.GetByStudentAndGroup(studentId, g!.Id)).ToList(),
Klausurergebnisse = examResults.GetByStudent(studentId),
Mitarbeit = participation.GetByStudent(studentId),
Dokumentation = documentation.GetByStudent(studentId),
};
return JsonSerializer.Serialize(dto, new JsonSerializerOptions
{
WriteIndented = true,
// Ohne diese Option würden Umlaute als \uXXXX escaped — schlecht lesbar für den
// eigentlichen Zweck dieses Exports (Auskunft an die betroffene Person).
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
}
}
@@ -0,0 +1,47 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
internal class PrivacySettingsConfig
{
public int RetentionYears { get; set; } = 3;
}
/// <summary>
/// Löschfristen für Dokumentationseinträge (5.4.2). Löscht nichts automatisch — schlägt
/// abgelaufene Einträge nur zur manuellen Prüfung/Löschung vor.
/// </summary>
public class PrivacySettingsService
{
private readonly string _configPath;
private PrivacySettingsConfig _config;
public int RetentionYears => _config.RetentionYears;
public PrivacySettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "privacy.json");
_config = Load();
}
public void SetRetentionYears(int years)
{
_config.RetentionYears = Math.Max(1, years);
File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
}
public DateTime RetentionCutoff(DateTime? now = null) =>
(now ?? DateTime.UtcNow).AddYears(-RetentionYears);
private PrivacySettingsConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<PrivacySettingsConfig>(File.ReadAllText(_configPath))
?? new PrivacySettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new PrivacySettingsConfig();
}
}