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? 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 }
@@ -54,6 +54,8 @@ 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;
// 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;
@@ -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,
});
}
@@ -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
@@ -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));
@@ -78,7 +78,7 @@
</Grid>
<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 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}"
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>
+17 -2
View File
@@ -76,8 +76,23 @@ public class ExamCoverSheetServiceTests
var wroteOriginal = ExamParticipantService.WroteOriginal(support, _ => originalResults);
Assert.Equal([wrote], wroteOriginal);
Assert.False(ExamParticipantService.Includes(support, null, false, wroteOriginal.Contains(wrote)));
Assert.True(ExamParticipantService.Includes(support, null, false, wroteOriginal.Contains(supported)));
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]
+2 -1
View File
@@ -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);
+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
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.
Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits in
[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
- `JoinedAt` / `LeftAt`: Grenzen eines eigenen Teilnahmezeitraums
- `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
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
`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
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`,
`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