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,17 @@
namespace LehrerApp.Core.Interfaces;
/// <summary>
/// Dateianhänge an Dokumentationseinträgen (z.B. der versendete Elternbrief als PDF).
/// Implementiert über den LiteDB-Dateispeicher — kein eigenes Dateisystem-Layout nötig.
/// </summary>
public interface IAttachmentStorage
{
/// Größte zulässige Anhangsgröße. Bewusst strikt begrenzt, damit die LiteDB-Datei
/// (und damit auch jedes Backup, 13.3.1) nicht durch große Anhänge aufgebläht wird.
const long MaxSizeBytes = 10 * 1024 * 1024;
/// Lädt den Inhalt hoch und gibt die Speicher-ID zurück (in <see cref="Models.DocumentAttachment.StorageId"/> zu speichern).
string Upload(string fileName, Stream content);
Stream? OpenRead(string storageId);
void Delete(string storageId);
}
@@ -89,8 +89,13 @@ public interface IDocumentationRepository
{
List<Documentation> GetByStudent(Guid studentId);
List<Documentation> GetByStudentAndType(Guid studentId, DocumentationType type);
/// Alle nicht gelöschten Einträge, über alle Schüler hinweg (z.B. für Dashboard-Auswertungen).
List<Documentation> GetAll();
void Save(Documentation doc);
/// Markiert den Eintrag als gelöscht, statt ihn hart zu entfernen (5.1.4).
void Delete(Guid id);
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
void HardDelete(Guid id);
}
public interface IWorkTaskRepository
{
+45 -1
View File
@@ -12,9 +12,17 @@ public class Documentation
public List<string> Participants { get; set; } = [];
public AbsenceData? AbsenceData { get; set; }
public SupportData? SupportData { get; set; }
public ParentCallData? ParentCallData { get; set; }
public ParentLetterData? ParentLetterData { get; set; }
public List<DocumentAttachment> Attachments { get; set; } = [];
/// Freie Labels zur Nachverfolgung, z.B. "Kritisch", "Nacharbeiten" — siehe `DocumentationTagDisplay`.
public List<string> Tags { get; set; } = [];
public bool IsConfidential { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Löschen entfernt den Eintrag nicht hart, sondern markiert ihn nur (Nachvollziehbarkeit, 5.1.4).
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}
public class AbsenceData
{
@@ -28,7 +36,43 @@ public class SupportData
public DateOnly? ReviewDate { get; set; }
public SupportStatus Status { get; set; } = SupportStatus.Active;
}
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence }
/// <summary>Geplante Gesprächspunkte eines Elternanrufs, abgehakt in <see cref="ParentCallData"/>.</summary>
public class ParentCallPoint
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Text { get; set; } = "";
public bool IsDone { get; set; }
}
/// <summary>
/// Elternanruf: Gesprächspunkte werden vorab geplant, dann im begleitenden Dialog während des
/// Anrufs abgehakt und um Eindrücke/Ergänzungen zum Protokoll ergänzt.
/// </summary>
public class ParentCallData
{
public List<ParentCallPoint> Points { get; set; } = [];
public string Impressions { get; set; } = "";
public bool IsConducted { get; set; }
public DateOnly? ConductedDate { get; set; }
}
/// <summary>Elternbrief: Entwurf/Planung sowie Absende- und Rückmeldedaten.</summary>
public class ParentLetterData
{
public string DraftContent { get; set; } = "";
public DateOnly? SentDate { get; set; }
public bool ResponseReceived { get; set; }
public DateOnly? ResponseDate { get; set; }
public string ResponseNote { get; set; } = "";
}
/// <summary>Datei-Anhang eines Dokumentationseintrags, im LiteDB-Dateispeicher abgelegt.</summary>
public class DocumentAttachment
{
public string StorageId { get; set; } = "";
public string FileName { get; set; } = "";
public long SizeBytes { get; set; }
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
}
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter }
public enum SupportStatus { Active, Completed, Paused }
public class WorkTask
@@ -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();
}
}