Förderschüler je Gruppe markieren und Förderarbeit darauf beschränken

GroupMembership.IsSupportStudent markiert Förderschüler unabhängig von der
Niveaudifferenzierung (Checkbox-Spalte in der Schülerliste; in differenzierten
Gruppen zählt das Förderniveau). Punkteeingabe und Deckblätter einer
Förderarbeit zeigen nur Förderschüler und Schüler mit vorhandenem Ergebnis.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5.5 committed 2026-10-22 21:00:56 +02:00
1 parent 651c9cddf8
commit 41649b0a05
12 files changed
+64 -5

No files matched your search

+5
View File
@@ -68,6 +68,11 @@ public class GroupMembership
public DateOnly? JoinedAt { get; set; } public DateOnly? JoinedAt { get; set; }
public DateOnly? LeftAt { get; set; } public DateOnly? LeftAt { get; set; }
public Niveau? Niveau { 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 MembershipPeriod { FullYear, H1Only, H2Only, Custom }
public enum GroupType { Class, Course } public enum GroupType { Class, Course }
@@ -54,6 +54,8 @@ public static class ExamParticipantService
// Niveau-Klausur: nur Schüler mit passendem Niveau. Klausuren ohne Niveau-Zuordnung gelten // Niveau-Klausur: nur Schüler mit passendem Niveau. Klausuren ohne Niveau-Zuordnung gelten
// weiterhin für die ganze Gruppe. // weiterhin für die ganze Gruppe.
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) return false; if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) return false;
// 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 — // 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). // außer es gibt hier bereits ein Ergebnis (nichts verstecken, was schon eingetragen wurde).
return hasResult || !wroteOriginal; return hasResult || !wroteOriginal;
@@ -10,6 +10,11 @@ public enum SchoolYearPeriodKind { FullYear, H1, H2 }
/// </summary> /// </summary>
public static class GroupMembershipService 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) public static bool IsActiveOn(GroupMembership membership, DateOnly date)
{ {
if (membership.JoinedAt is { } joinedAt && date < joinedAt) return false; if (membership.JoinedAt is { } joinedAt && date < joinedAt) return false;
@@ -80,6 +80,7 @@ public sealed class GroupRolloverService(
JoinedAt = targetStart, JoinedAt = targetStart,
LeftAt = null, LeftAt = null,
Niveau = membership.Niveau, Niveau = membership.Niveau,
IsSupportStudent = membership.IsSupportStudent,
}); });
} }
@@ -17,6 +17,7 @@ public class GroupMembershipTools(
[Description("Schüler-ID.")] Guid studentId, [Description("Schüler-ID.")] Guid studentId,
[Description("Lerngruppen-ID.")] Guid groupId, [Description("Lerngruppen-ID.")] Guid groupId,
[Description("Niveau: E, G oder Foerder. Unverändert lassen: weglassen.")] Niveau? niveau = null, [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("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("Beitrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? joinedAt = null,
[Description("Austrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? leftAt = null, [Description("Austrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? leftAt = null,
@@ -34,6 +35,7 @@ public class GroupMembershipTools(
var changes = new StringBuilder(); var changes = new StringBuilder();
if (niveau is not null && niveau != target.Niveau) { changes.AppendLine($"Niveau: {NiveauName(target.Niveau)} → {NiveauName(niveau)}"); target.Niveau = niveau; } 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 (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 (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; } 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, Id = m.Id, StudentId = m.StudentId, GroupId = m.GroupId, AddedOn = m.AddedOn,
Period = m.Period, JoinedAt = m.JoinedAt, LeftAt = m.LeftAt, Niveau = m.Niveau, Period = m.Period, JoinedAt = m.JoinedAt, LeftAt = m.LeftAt, Niveau = m.Niveau,
IsSupportStudent = m.IsSupportStudent,
}; };
private static string NiveauName(Niveau? n) => n switch private static string NiveauName(Niveau? n) => n switch
@@ -198,6 +198,8 @@ public partial class GroupDetailViewModel : ObservableObject
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann // 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. // kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen.
public bool IsDifferentiated => Group?.IsDifferentiated ?? false; 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 IsReadOnly => Group is { IsActive: false };
public bool IsEditable => Group is { IsActive: true }; public bool IsEditable => Group is { IsActive: true };
public string SubjectName { get; private set; } = ""; public string SubjectName { get; private set; } = "";
@@ -205,6 +207,7 @@ public partial class GroupDetailViewModel : ObservableObject
partial void OnGroupChanged(LearningGroup? value) partial void OnGroupChanged(LearningGroup? value)
{ {
OnPropertyChanged(nameof(IsDifferentiated)); OnPropertyChanged(nameof(IsDifferentiated));
OnPropertyChanged(nameof(ShowSupportColumn));
OnPropertyChanged(nameof(IsReadOnly)); OnPropertyChanged(nameof(IsReadOnly));
OnPropertyChanged(nameof(IsEditable)); OnPropertyChanged(nameof(IsEditable));
NotifyWriteCommands(); NotifyWriteCommands();
@@ -324,6 +327,7 @@ public partial class GroupDetailViewModel : ObservableObject
var membership = _memberships.GetByStudentAndGroup(summary.Id, Group.Id); var membership = _memberships.GetByStudentAndGroup(summary.Id, Group.Id);
if (membership is null) return; if (membership is null) return;
membership.Niveau = summary.Niveau; membership.Niveau = summary.Niveau;
membership.IsSupportStudent = summary.IsSupportStudent;
_memberships.Save(membership); _memberships.Save(membership);
} }
@@ -587,6 +591,7 @@ public partial class StudentSummary : ObservableObject
public string AvatarTooltip { get; } public string AvatarTooltip { get; }
[ObservableProperty] private Niveau? _niveau; [ObservableProperty] private Niveau? _niveau;
[ObservableProperty] private bool _isSupportStudent;
public string NiveauName 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}" ? IsFormer ? $"Ausgetreten am {exitDate:dd.MM.yyyy}" : $"Austritt am {exitDate:dd.MM.yyyy}"
: "Aktiv"; : "Aktiv";
_niveau = membership?.Niveau; _niveau = membership?.Niveau;
_isSupportStudent = membership?.IsSupportStudent ?? false;
} }
partial void OnIsSupportStudentChanged(bool value) => OnChanged?.Invoke(this);
partial void OnNiveauChanged(Niveau? value) partial void OnNiveauChanged(Niveau? value)
{ {
OnPropertyChanged(nameof(NiveauName)); OnPropertyChanged(nameof(NiveauName));
@@ -78,7 +78,7 @@
</Grid> </Grid>
<ComboBox ItemsSource="{Binding FollowUpKindOptions}" SelectedItem="{Binding FollowUpKindName}" <ComboBox ItemsSource="{Binding FollowUpKindOptions}" SelectedItem="{Binding FollowUpKindName}"
IsVisible="{Binding HasSelectedOriginalExam}" HorizontalAlignment="Stretch" 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 einzelne Schüler, 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."/> 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}" <ComboBox ItemsSource="{Binding RepeatRuleOptions}" SelectedItem="{Binding RepeatRuleName}"
IsVisible="{Binding ShowRepeatRule}"/> IsVisible="{Binding ShowRepeatRule}"/>
<TextBlock Text="{Binding RepeatError}" Foreground="Red" FontSize="11" <TextBlock Text="{Binding RepeatError}" Foreground="Red" FontSize="11"
@@ -96,6 +96,16 @@
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </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.Columns>
</DataGrid> </DataGrid>
</ContentPage> </ContentPage>
+17 -2
View File
@@ -76,8 +76,23 @@ public class ExamCoverSheetServiceTests
var wroteOriginal = ExamParticipantService.WroteOriginal(support, _ => originalResults); var wroteOriginal = ExamParticipantService.WroteOriginal(support, _ => originalResults);
Assert.Equal([wrote], wroteOriginal); Assert.Equal([wrote], wroteOriginal);
Assert.False(ExamParticipantService.Includes(support, null, false, wroteOriginal.Contains(wrote))); var flagged = new GroupMembership { IsSupportStudent = true };
Assert.True(ExamParticipantService.Includes(support, null, false, wroteOriginal.Contains(supported))); 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] [Fact]
+2 -1
View File
@@ -19,7 +19,7 @@ public sealed class GroupRolloverServiceTests
var oldMembership = new GroupMembership var oldMembership = new GroupMembership
{ {
StudentId = Guid.NewGuid(), GroupId = source.Id, Period = MembershipPeriod.H2Only, 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 groups = new MemoryGroups([source]);
var memberships = new MemoryMemberships([oldMembership]); var memberships = new MemoryMemberships([oldMembership]);
@@ -48,6 +48,7 @@ public sealed class GroupRolloverServiceTests
Assert.Equal(new DateOnly(2026, 8, 1), copied.JoinedAt); Assert.Equal(new DateOnly(2026, 8, 1), copied.JoinedAt);
Assert.Null(copied.LeftAt); Assert.Null(copied.LeftAt);
Assert.Equal(Niveau.E, copied.Niveau); Assert.Equal(Niveau.E, copied.Niveau);
Assert.True(copied.IsSupportStudent);
var scheme = schemes.GetByGroup(target.Id); var scheme = schemes.GetByGroup(target.Id);
Assert.NotNull(scheme); Assert.NotNull(scheme);
+4
View File
@@ -70,6 +70,10 @@ Diese Regeln gelten für jede Aufgabe unten und müssen nicht wiederholt werden.
(`ExamFollowUpExtensions.ReplacesMissingGrade`): ihre Note ersetzt nur eine fehlende Note, die (`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 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. 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.
Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits in Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits in
[Exam.cs](LehrerApp.Core/Models/Exam.cs), Repositories ebenfalls. Die komplette UI fehlt. [Exam.cs](LehrerApp.Core/Models/Exam.cs), Repositories ebenfalls. Die komplette UI fehlt.
+6 -1
View File
@@ -29,6 +29,9 @@ keine Aufnahme oder Einschreibung an der Schule.
- `Period`: ganzes Schuljahr, erstes Halbjahr, zweites Halbjahr oder eigener Zeitraum - `Period`: ganzes Schuljahr, erstes Halbjahr, zweites Halbjahr oder eigener Zeitraum
- `JoinedAt` / `LeftAt`: Grenzen eines eigenen Teilnahmezeitraums - `JoinedAt` / `LeftAt`: Grenzen eines eigenen Teilnahmezeitraums
- `Niveau`: optionale Niveaudifferenzierung - `Niveau`: optionale Niveaudifferenzierung
- `IsSupportStudent`: Förderschüler-Markierung je Gruppe, unabhängig von der Niveaudifferenzierung
(auch ein nicht differenzierter Kurs kann Förderschüler mit eigenem Material haben); als
Förderschüler gilt außerdem, wer das Niveau `Foerder` hat (`GroupMembershipService.IsSupportStudent`)
Das Schuljahr wird über die Lerngruppe ermittelt und deshalb nicht zusätzlich Das Schuljahr wird über die Lerngruppe ermittelt und deshalb nicht zusätzlich
in der Gruppenzuordnung gespeichert. Für eine spätere echte Schulaufnahme wäre in der Gruppenzuordnung gespeichert. Für eine spätere echte Schulaufnahme wäre
@@ -145,7 +148,9 @@ Einige berechnete Werte bleiben absichtlich gespeichert:
berechneten Durchschnitts- und Zeugnisnoten ein. `Exam.IsMakeUp` (Nachschreibarbeit) und berechneten Durchschnitts- und Zeugnisnoten ein. `Exam.IsMakeUp` (Nachschreibarbeit) und
`Exam.IsSupportWork` (Förderarbeit) markieren Folgearbeiten nur für einzelne Schüler; ihre Note `Exam.IsSupportWork` (Förderarbeit) markieren Folgearbeiten nur für einzelne Schüler; ihre Note
ersetzt ausschließlich eine fehlende Note der ersten Arbeit. Anders als Nachschreibarbeiten darf ersetzt ausschließlich eine fehlende Note der ersten Arbeit. Anders als Nachschreibarbeiten darf
eine Förderarbeit am selben Tag wie die erste Arbeit liegen (sie wird parallel geschrieben). eine Förderarbeit am selben Tag wie die erste Arbeit liegen (sie wird parallel geschrieben). Die
Punkteeingabe einer Förderarbeit zeigt nur Förderschüler der Gruppe (`GroupMembership.IsSupportStudent`
oder Niveau `Foerder`) sowie Schüler mit bereits vorhandenem Ergebnis.
- `CompetencyReference` (`Unit.Competencies`, `Lesson.Competencies`, `ExamTask.Competencies`, - `CompetencyReference` (`Unit.Competencies`, `Lesson.Competencies`, `ExamTask.Competencies`,
`ParticipationSession.Competencies`) verweist über `ItemId` auf ein `CompetencyItem`, hält aber `ParticipationSession.Competencies`) verweist über `ItemId` auf ein `CompetencyItem`, hält aber
zusätzlich `Code`/`Description` zum Zuordnungszeitpunkt fest (TODO 16.5, abgelöst von einer reinen zusätzlich `Code`/`Description` zum Zuordnungszeitpunkt fest (TODO 16.5, abgelöst von einer reinen