added FS questionnaire
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
@page "/admin"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize(Roles = "ROLE_ADMIN")]
|
||||
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
|
||||
@inject Fluxor.IDispatcher Dispatcher
|
||||
@inject Fluxor.IState<Wollmilchsau.Store.AdminStore.AdminState> AdminState
|
||||
@inject IJSRuntime JsRuntime
|
||||
|
||||
<PageTitle>LDAP Users</PageTitle>
|
||||
|
||||
<button class="btn btn-primary" @onclick="DownloadCsvAsync" disabled="@(!Users.Any())">Export CSV</button>
|
||||
|
||||
<h1>LDAP Users</h1>
|
||||
|
||||
@if (!AdminState.Value.LdapUsersLoaded)
|
||||
{
|
||||
<p><em>Loading users...</em></p>
|
||||
}
|
||||
else if (!Users.Any())
|
||||
{
|
||||
<p>No LDAP users found.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Surname</th>
|
||||
<th>Classroom</th>
|
||||
<th>Given name</th>
|
||||
<th>Incomplete</th>
|
||||
<th>Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var user in Users)
|
||||
{
|
||||
<tr>
|
||||
<td>@user.Username</td>
|
||||
<td>@user.Surname</td>
|
||||
<td>@user.Classroom</td>
|
||||
<td>@user.GivenName</td>
|
||||
<td>@user.Incomplete</td>
|
||||
<td>@user.Role</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IEnumerable<Wollmilchsau.Models.LdapUser> Users => AdminState.Value.LdapUsers.Values;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers());
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms());
|
||||
}
|
||||
|
||||
private Task DownloadCsvAsync()
|
||||
{
|
||||
var csv = string.Join("\r\n", Users
|
||||
.Where(user => user.Role == Wollmilchsau.Models.LdapUserRole.Student)
|
||||
.Select(user => string.Join(';',
|
||||
user.GivenName ?? string.Empty,
|
||||
user.Surname ?? string.Empty,
|
||||
user.Classroom ?? string.Empty,
|
||||
user.Username)));
|
||||
|
||||
return JsRuntime.InvokeVoidAsync("downloadTextFile", "ldap-users.csv", csv).AsTask();
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,56 @@
|
||||
@page "/"
|
||||
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
|
||||
@inject Fluxor.IDispatcher Dispatcher
|
||||
@inject Fluxor.IState<Wollmilchsau.Store.AdminStore.AdminState> AdminState
|
||||
@inject IJSRuntime JsRuntime
|
||||
|
||||
<PageTitle>LDAP Users</PageTitle>
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<button class="btn btn-primary" @onclick="DownloadCsvAsync" disabled="@(!Users.Any())">Export CSV</button>
|
||||
<MudStack Spacing="10">
|
||||
<AuthorizeView Roles="ROLE_STUDENT,ROLE_TEACHER,ROLE_ADMIN">
|
||||
<Authorized>
|
||||
<MudContainer>
|
||||
<MudCard Style="width: 300px;">
|
||||
<MudCardMedia Image="images/questionnaire.svg" Height="200" Style="width: 300px;" />
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h5">Umfragen</MudText>
|
||||
<MudText Typo="Typo.body2">Hier kannst du an Umfragen teilnehmen.</MudText>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/students/questionaire">Umfragen</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
<h1>LDAP Users</h1>
|
||||
<AuthorizeView Roles="ROLE_TEACHER,ROLE_ADMIN">
|
||||
<Authorized>
|
||||
<MudContainer>
|
||||
<MudCard Style="width: 300px;">
|
||||
<MudCardMedia Image="images/questionnaire-results.svg" Height="200" Style="width: 300px;" />
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h5">Umfrageergebnisse</MudText>
|
||||
<MudText Typo="Typo.body2">Hier kannst du die Ergebnisse von Umfragen auswerten.</MudText>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/teachers/questionaire-results">Ergebnisse</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@if (!AdminState.Value.LdapUsersLoaded)
|
||||
{
|
||||
<p><em>Loading users...</em></p>
|
||||
}
|
||||
else if (!Users.Any())
|
||||
{
|
||||
<p>No LDAP users found.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Surname</th>
|
||||
<th>Classroom</th>
|
||||
<th>Given name</th>
|
||||
<th>Incomplete</th>
|
||||
<th>Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var user in Users)
|
||||
{
|
||||
<tr>
|
||||
<td>@user.Username</td>
|
||||
<td>@user.Surname</td>
|
||||
<td>@user.Classroom</td>
|
||||
<td>@user.GivenName</td>
|
||||
<td>@user.Incomplete</td>
|
||||
<td>@user.Role</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IEnumerable<Wollmilchsau.Models.LdapUser> Users => AdminState.Value.LdapUsers.Values;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers());
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms());
|
||||
}
|
||||
|
||||
private Task DownloadCsvAsync()
|
||||
{
|
||||
var csv = string.Join("\r\n", Users
|
||||
.Where(user => user.Role == Wollmilchsau.Models.LdapUserRole.Student)
|
||||
.Select(user => string.Join(';',
|
||||
user.GivenName ?? string.Empty,
|
||||
user.Surname ?? string.Empty,
|
||||
user.Classroom ?? string.Empty,
|
||||
user.Username)));
|
||||
|
||||
return JsRuntime.InvokeVoidAsync("downloadTextFile", "ldap-users.csv", csv).AsTask();
|
||||
}
|
||||
}
|
||||
<AuthorizeView Roles="ROLE_ADMIN">
|
||||
<Authorized>
|
||||
<MudContainer>
|
||||
<MudCard Style="width: 300px;">
|
||||
<MudCardMedia Image="images/administration.svg" Height="200" Style="width: 300px;" />
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h5">Administration</MudText>
|
||||
<MudText Typo="Typo.body2">Hier kannst du Benutzer und Klassen verwalten.</MudText>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/admin">Administration</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</MudStack>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
@implements IDisposable
|
||||
|
||||
@if (!HasExpired)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
|
||||
Verbleibende Zeit bis zur Abgabe: @FormatRemainingTime()
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public DateTimeOffset Deadline { get; set; }
|
||||
|
||||
private System.Threading.Timer? countdownTimer;
|
||||
|
||||
private bool HasExpired => Deadline <= DateTimeOffset.UtcNow;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
countdownTimer = new System.Threading.Timer(
|
||||
_ => _ = InvokeAsync(StateHasChanged),
|
||||
null,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
public void Dispose() => countdownTimer?.Dispose();
|
||||
|
||||
private string FormatRemainingTime()
|
||||
{
|
||||
var remaining = Deadline - DateTimeOffset.UtcNow;
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
return "expired";
|
||||
}
|
||||
|
||||
var units = new[]
|
||||
{
|
||||
FormatUnit(remaining.Days, "Tag"),
|
||||
FormatUnit(remaining.Hours, "Stunde"),
|
||||
FormatUnit(remaining.Minutes, "Minute")
|
||||
}.Where(unit => unit is not null).ToArray();
|
||||
|
||||
return units.Length == 0 ? "weniger als 1 Minute" : string.Join(", ", units);
|
||||
}
|
||||
|
||||
private static string? FormatUnit(int value, string unit) =>
|
||||
value == 0 ? null : $"{value} {unit}{(value == 1 ? string.Empty : "n")}";
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
@page "/students/questionaire"
|
||||
@using System.Security.Claims
|
||||
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject Fluxor.IDispatcher Dispatcher
|
||||
@inject Fluxor.IState<Wollmilchsau.Store.QuestionnaireStore.QuestionnaireState> QuestionnaireState
|
||||
|
||||
<PageTitle>Kurswahl</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-6 mb-6">
|
||||
<MudText Typo="Typo.h3" GutterBottom="true">Kurswahl</MudText>
|
||||
<MudText Typo="Typo.body1" Class="mb-6">Wählen Sie vier verschiedene Optionen für die vier aufeinanderfolgenden Halbjahre. Vervollständigen Sie drei unterschiedliche Alternativen, damit wir diese berücksichtigen können, falls Ihre erste Wahl nicht verfügbar ist.</MudText>
|
||||
|
||||
<DeadlineCountdown Deadline="Wollmilchsau.Store.QuestionnaireStore.QuestionnaireSubmissionPolicy.Deadline" />
|
||||
|
||||
@if (!QuestionnaireState.Value.IsLoading && !QuestionnaireState.Value.ParticipationAllowed)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Filled" Class="mb-4">@Wollmilchsau.Store.QuestionnaireStore.QuestionnaireSubmissionPolicy.ClassroomNotAllowedMessage</MudAlert>
|
||||
}
|
||||
else if (QuestionnaireState.Value.SubmissionClosed)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Filled" Class="mb-4">@Wollmilchsau.Store.QuestionnaireStore.QuestionnaireSubmissionPolicy.ClosedMessage Ihre gespeicherten Alternativen werden unten angezeigt.</MudAlert>
|
||||
}
|
||||
else if (QuestionnaireState.Value.Saved)
|
||||
{
|
||||
<MudAlert Severity="Severity.Success" Variant="Variant.Filled" Class="mb-4">Ihre drei Alternativen wurden gespeichert. Sie können sie bis Dienstag, 25. August 2026 um 10:00 MEST aktualisieren.</MudAlert>
|
||||
}
|
||||
|
||||
@if (QuestionnaireState.Value.ShowValidationErrors && QuestionnaireState.Value.ValidationError is not null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="mb-4">@QuestionnaireState.Value.ValidationError</MudAlert>
|
||||
}
|
||||
|
||||
<MudStack Spacing="4">
|
||||
@for (var distributionIndex = 0; distributionIndex < QuestionnaireState.Value.Distributions.Count; distributionIndex++)
|
||||
{
|
||||
var currentDistributionIndex = distributionIndex;
|
||||
var alternativeNumber = currentDistributionIndex + 1;
|
||||
<MudPaper Outlined="true" Class="pa-4">
|
||||
<MudText Typo="Typo.h5">Alternative @alternativeNumber</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">Weisen Sie jedem Halbjahr eine andere Option zu.</MudText>
|
||||
|
||||
<MudGrid Spacing="3">
|
||||
@for (var semesterIndex = 0; semesterIndex < 4; semesterIndex++)
|
||||
{
|
||||
var currentSemesterIndex = semesterIndex;
|
||||
var semesterLabel = $"Semester {currentSemesterIndex + 1}";
|
||||
var selectedOption = QuestionnaireState.Value.Distributions[currentDistributionIndex][currentSemesterIndex];
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="string"
|
||||
Label="@semesterLabel"
|
||||
Value="@selectedOption"
|
||||
ValueChanged="option => SetSemesterOption(currentDistributionIndex, currentSemesterIndex, option)"
|
||||
Error="@HasDistributionError(currentDistributionIndex)"
|
||||
ErrorText="Wählen Sie eine eindeutige Option für diese Alternative."
|
||||
Disabled="@(QuestionnaireState.Value.IsLoading || !QuestionnaireState.Value.ParticipationAllowed || QuestionnaireState.Value.SubmissionClosed)"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true">
|
||||
@foreach (var option in Wollmilchsau.Store.QuestionnaireStore.QuestionnaireValidation.Options)
|
||||
{
|
||||
<MudSelectItem Value="@option">@option</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Large" OnClick="SaveAsync" Disabled="@(QuestionnaireState.Value.IsLoading || !QuestionnaireState.Value.ParticipationAllowed || QuestionnaireState.Value.SubmissionClosed)" FullWidth="true">Auswahl speichern</MudButton>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var user = authenticationState.User;
|
||||
var studentId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user.FindFirst("sub")?.Value ?? user.Identity?.Name;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(studentId))
|
||||
{
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.QuestionnaireStore.LoadQuestionnaire(studentId, user.FindFirst("classroom")?.Value));
|
||||
}
|
||||
}
|
||||
|
||||
private void SetSemesterOption(int distributionIndex, int semesterIndex, string? option) =>
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.QuestionnaireStore.SetSemesterOption(distributionIndex, semesterIndex, option));
|
||||
|
||||
private Task SaveAsync()
|
||||
{
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.QuestionnaireStore.SaveQuestionnaire());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool HasDistributionError(int distributionIndex) =>
|
||||
QuestionnaireState.Value.ShowValidationErrors && IsInvalidDistribution(distributionIndex);
|
||||
|
||||
private bool IsInvalidDistribution(int distributionIndex)
|
||||
{
|
||||
var distribution = QuestionnaireState.Value.Distributions[distributionIndex];
|
||||
return distribution.Any(string.IsNullOrWhiteSpace)
|
||||
|| distribution.Distinct(StringComparer.Ordinal).Count() != distribution.Count
|
||||
|| QuestionnaireState.Value.Distributions.Where((_, index) => index != distributionIndex)
|
||||
.Any(other => other.SequenceEqual(distribution, StringComparer.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
@page "/teachers/questionaire-results"
|
||||
@using Marten
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Wollmilchsau.Models
|
||||
@using Wollmilchsau.Services.Ldap
|
||||
@attribute [Authorize(Roles = "ROLE_TEACHER,ROLE_ADMIN")]
|
||||
@inject ILdapService LdapService
|
||||
@inject IDocumentSession DocumentSession
|
||||
|
||||
<PageTitle>Questionnaire results</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-6 mb-6">
|
||||
<MudText Typo="Typo.h3" GutterBottom="true">Questionnaire results</MudText>
|
||||
<MudText Typo="Typo.body1" Class="mb-6">Eligible students and their submitted semester preferences.</MudText>
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Class="py-8">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
|
||||
<MudText>Loading questionnaire results...</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else if (results.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">No eligible students were found.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
|
||||
@CompletedCount of @results.Count eligible students have submitted their choices.
|
||||
</MudAlert>
|
||||
|
||||
<MudStack Spacing="4">
|
||||
@foreach (var classroom in results.GroupBy(result => result.Classroom).OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<MudPaper Outlined="true" Class="pa-4">
|
||||
<MudText Typo="Typo.h5" GutterBottom="true">@classroom.Key</MudText>
|
||||
|
||||
<MudStack Spacing="3">
|
||||
@foreach (var result in classroom)
|
||||
{
|
||||
<MudPaper Elevation="0" Outlined="true" Class="pa-3">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-3">
|
||||
<div>
|
||||
<MudText Typo="Typo.h6">@result.DisplayName</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@result.Username</MudText>
|
||||
</div>
|
||||
<MudChip T="string" Color="@(result.Submission is null ? Color.Warning : Color.Success)" Variant="Variant.Filled" Size="Size.Small">
|
||||
@(result.Submission is null ? "Not submitted" : "Submitted")
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
|
||||
@if (result.Submission is null)
|
||||
{
|
||||
<MudText Color="Color.Secondary">This student has not made a choice yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">Last updated: @result.Submission.SubmittedAt.LocalDateTime.ToString("g")</MudText>
|
||||
<MudGrid Spacing="2">
|
||||
@for (var alternativeIndex = 0; alternativeIndex < result.Submission.Alternatives.Count; alternativeIndex++)
|
||||
{
|
||||
var currentAlternativeIndex = alternativeIndex;
|
||||
var alternative = result.Submission.Alternatives[currentAlternativeIndex];
|
||||
<MudItem xs="12" md="4">
|
||||
<MudPaper Outlined="true" Class="pa-3 h-100">
|
||||
<MudText Typo="Typo.subtitle2" GutterBottom="true">Alternative @(currentAlternativeIndex + 1)</MudText>
|
||||
@for (var semesterIndex = 0; semesterIndex < alternative.Count; semesterIndex++)
|
||||
{
|
||||
<MudText Typo="Typo.body2">Semester @(semesterIndex + 1): @alternative[semesterIndex]</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<StudentQuestionnaireResult> results = [];
|
||||
private bool isLoading = true;
|
||||
|
||||
private int CompletedCount => results.Count(result => result.Submission is not null);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var submissions = await DocumentSession.Query<QuestionnaireSubmission>().ToListAsync();
|
||||
var submissionsByStudent = submissions
|
||||
.GroupBy(submission => submission.StudentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.OrderByDescending(submission => submission.SubmittedAt).First(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var students = new List<LdapUser>();
|
||||
|
||||
await foreach (var user in LdapService.GetAllUsers())
|
||||
{
|
||||
if (user.Role == LdapUserRole.Student &&
|
||||
Wollmilchsau.Store.QuestionnaireStore.QuestionnaireSubmissionPolicy.IsClassroomAllowed(user.Classroom))
|
||||
{
|
||||
students.Add(user);
|
||||
}
|
||||
}
|
||||
|
||||
results = students
|
||||
.OrderBy(student => student.Classroom, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(student => student.Surname, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(student => student.GivenName, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(student => new StudentQuestionnaireResult(
|
||||
student.Username,
|
||||
student.Classroom!,
|
||||
GetDisplayName(student),
|
||||
submissionsByStudent.GetValueOrDefault(student.Username)))
|
||||
.ToList();
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
private static string GetDisplayName(LdapUser student)
|
||||
{
|
||||
var displayName = string.Join(" ", new[] { student.GivenName, student.Surname }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
return string.IsNullOrWhiteSpace(displayName) ? student.Username : displayName;
|
||||
}
|
||||
|
||||
private sealed record StudentQuestionnaireResult(
|
||||
string Username,
|
||||
string Classroom,
|
||||
string DisplayName,
|
||||
QuestionnaireSubmission? Submission);
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
<Fluxor.Blazor.Web.StoreInitializer />
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<p>You are not authorized to access this page.</p>
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
</Found>
|
||||
</Router>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Wollmilchsau.Models;
|
||||
|
||||
public sealed class QuestionnaireSubmission
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
public required string StudentId { get; init; }
|
||||
public string? Classroom { get; init; }
|
||||
public DateTimeOffset SubmittedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public required IReadOnlyList<IReadOnlyList<string>> Alternatives { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace Wollmilchsau.Store.QuestionnaireStore;
|
||||
|
||||
public sealed record LoadQuestionnaire(string StudentId, string? Classroom);
|
||||
public sealed record QuestionnaireLoaded(ImmutableList<ImmutableList<string?>> Distributions, bool SubmissionClosed);
|
||||
public sealed record QuestionnaireSubmissionClosed();
|
||||
public sealed record SetSemesterOption(int DistributionIndex, int SemesterIndex, string? Option);
|
||||
public sealed record SaveQuestionnaire();
|
||||
public sealed record QuestionnaireSaveSucceeded();
|
||||
public sealed record QuestionnaireSaveRejected(string Error);
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Collections.Immutable;
|
||||
using Fluxor;
|
||||
using Marten;
|
||||
using Wollmilchsau.Models;
|
||||
|
||||
namespace Wollmilchsau.Store.QuestionnaireStore;
|
||||
|
||||
public sealed class QuestionnaireEffects(IState<QuestionnaireState> questionnaireState, IDocumentSession session)
|
||||
{
|
||||
[EffectMethod]
|
||||
public async Task HandleLoadQuestionnaire(LoadQuestionnaire action, IDispatcher dispatcher)
|
||||
{
|
||||
if (!QuestionnaireSubmissionPolicy.IsClassroomAllowed(action.Classroom))
|
||||
{
|
||||
dispatcher.Dispatch(new QuestionnaireLoaded(
|
||||
QuestionnaireState.CreateEmptyDistributions(),
|
||||
QuestionnaireSubmissionPolicy.IsSubmissionClosed(DateTimeOffset.UtcNow)));
|
||||
return;
|
||||
}
|
||||
|
||||
var existingSubmission = await session.Query<QuestionnaireSubmission>()
|
||||
.FirstOrDefaultAsync(submission => submission.StudentId == action.StudentId);
|
||||
var distributions = existingSubmission is null
|
||||
? QuestionnaireState.CreateEmptyDistributions()
|
||||
: ToDistributions(existingSubmission);
|
||||
|
||||
dispatcher.Dispatch(new QuestionnaireLoaded(
|
||||
distributions,
|
||||
QuestionnaireSubmissionPolicy.IsSubmissionClosed(DateTimeOffset.UtcNow)));
|
||||
}
|
||||
|
||||
[EffectMethod]
|
||||
public async Task HandleSaveQuestionnaire(SaveQuestionnaire action, IDispatcher dispatcher)
|
||||
{
|
||||
var state = questionnaireState.Value;
|
||||
if (!QuestionnaireSubmissionPolicy.IsClassroomAllowed(state.Classroom))
|
||||
{
|
||||
dispatcher.Dispatch(new QuestionnaireSaveRejected(QuestionnaireSubmissionPolicy.ClassroomNotAllowedMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
if (QuestionnaireSubmissionPolicy.IsSubmissionClosed(DateTimeOffset.UtcNow))
|
||||
{
|
||||
dispatcher.Dispatch(new QuestionnaireSubmissionClosed());
|
||||
dispatcher.Dispatch(new QuestionnaireSaveRejected(QuestionnaireSubmissionPolicy.ClosedMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
var validationError = QuestionnaireValidation.Validate(state.Distributions);
|
||||
if (validationError is not null)
|
||||
{
|
||||
dispatcher.Dispatch(new QuestionnaireSaveRejected(validationError));
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(state.StudentId))
|
||||
{
|
||||
dispatcher.Dispatch(new QuestionnaireSaveRejected("Your student identity could not be determined."));
|
||||
return;
|
||||
}
|
||||
|
||||
var existingSubmission = await session.Query<QuestionnaireSubmission>()
|
||||
.FirstOrDefaultAsync(submission => submission.StudentId == state.StudentId);
|
||||
|
||||
session.Store(new QuestionnaireSubmission
|
||||
{
|
||||
Id = existingSubmission?.Id ?? Guid.NewGuid(),
|
||||
StudentId = state.StudentId,
|
||||
Classroom = state.Classroom,
|
||||
SubmittedAt = DateTimeOffset.UtcNow,
|
||||
Alternatives = state.Distributions
|
||||
.Select(distribution => (IReadOnlyList<string>)distribution.Select(option => option!).ToArray())
|
||||
.ToArray()
|
||||
});
|
||||
await session.SaveChangesAsync();
|
||||
|
||||
dispatcher.Dispatch(new QuestionnaireSaveSucceeded());
|
||||
}
|
||||
|
||||
private static ImmutableList<ImmutableList<string?>> ToDistributions(QuestionnaireSubmission submission)
|
||||
{
|
||||
if (submission.Alternatives.Count != 3 || submission.Alternatives.Any(distribution => distribution.Count != 4))
|
||||
{
|
||||
return QuestionnaireState.CreateEmptyDistributions();
|
||||
}
|
||||
|
||||
return submission.Alternatives
|
||||
.Select(distribution => distribution.Select(option => (string?)option).ToImmutableList())
|
||||
.ToImmutableList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Collections.Immutable;
|
||||
using Fluxor;
|
||||
|
||||
namespace Wollmilchsau.Store.QuestionnaireStore;
|
||||
|
||||
public static class QuestionnaireReducers
|
||||
{
|
||||
[ReducerMethod]
|
||||
public static QuestionnaireState ReduceLoadQuestionnaire(QuestionnaireState state, LoadQuestionnaire action) => state with
|
||||
{
|
||||
StudentId = action.StudentId,
|
||||
Classroom = action.Classroom,
|
||||
Distributions = QuestionnaireState.CreateEmptyDistributions(),
|
||||
IsLoading = true,
|
||||
ParticipationAllowed = QuestionnaireSubmissionPolicy.IsClassroomAllowed(action.Classroom),
|
||||
SubmissionClosed = false,
|
||||
ShowValidationErrors = false,
|
||||
ValidationError = null,
|
||||
Saved = false
|
||||
};
|
||||
|
||||
[ReducerMethod]
|
||||
public static QuestionnaireState ReduceQuestionnaireLoaded(QuestionnaireState state, QuestionnaireLoaded action) => state with
|
||||
{
|
||||
Distributions = action.Distributions,
|
||||
IsLoading = false,
|
||||
SubmissionClosed = action.SubmissionClosed
|
||||
};
|
||||
|
||||
[ReducerMethod]
|
||||
public static QuestionnaireState ReduceQuestionnaireSubmissionClosed(QuestionnaireState state, QuestionnaireSubmissionClosed action) => state with
|
||||
{
|
||||
SubmissionClosed = true,
|
||||
Saved = false
|
||||
};
|
||||
|
||||
[ReducerMethod]
|
||||
public static QuestionnaireState ReduceSetSemesterOption(QuestionnaireState state, SetSemesterOption action)
|
||||
{
|
||||
if (state.IsLoading || !state.ParticipationAllowed || state.SubmissionClosed)
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
var distributions = state.Distributions.ToArray();
|
||||
distributions[action.DistributionIndex] = distributions[action.DistributionIndex].SetItem(action.SemesterIndex, action.Option);
|
||||
var updatedState = state with
|
||||
{
|
||||
Distributions = distributions.ToImmutableList(),
|
||||
Saved = false
|
||||
};
|
||||
|
||||
return updatedState with
|
||||
{
|
||||
ValidationError = updatedState.ShowValidationErrors
|
||||
? QuestionnaireValidation.Validate(updatedState.Distributions)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
[ReducerMethod]
|
||||
public static QuestionnaireState ReduceSaveQuestionnaire(QuestionnaireState state, SaveQuestionnaire action) => state with
|
||||
{
|
||||
ShowValidationErrors = true,
|
||||
ValidationError = !state.ParticipationAllowed
|
||||
? QuestionnaireSubmissionPolicy.ClassroomNotAllowedMessage
|
||||
: state.SubmissionClosed
|
||||
? QuestionnaireSubmissionPolicy.ClosedMessage
|
||||
: QuestionnaireValidation.Validate(state.Distributions),
|
||||
Saved = false
|
||||
};
|
||||
|
||||
[ReducerMethod]
|
||||
public static QuestionnaireState ReduceQuestionnaireSaveSucceeded(QuestionnaireState state, QuestionnaireSaveSucceeded action) => state with
|
||||
{
|
||||
Saved = true,
|
||||
ValidationError = null
|
||||
};
|
||||
|
||||
[ReducerMethod]
|
||||
public static QuestionnaireState ReduceQuestionnaireSaveRejected(QuestionnaireState state, QuestionnaireSaveRejected action) => state with
|
||||
{
|
||||
ShowValidationErrors = true,
|
||||
ValidationError = action.Error,
|
||||
Saved = false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Collections.Immutable;
|
||||
using Fluxor;
|
||||
|
||||
namespace Wollmilchsau.Store.QuestionnaireStore;
|
||||
|
||||
[FeatureState]
|
||||
public sealed record QuestionnaireState
|
||||
{
|
||||
public QuestionnaireState() { }
|
||||
|
||||
public string StudentId { get; init; } = string.Empty;
|
||||
public string? Classroom { get; init; }
|
||||
public ImmutableList<ImmutableList<string?>> Distributions { get; init; } = CreateEmptyDistributions();
|
||||
public bool IsLoading { get; init; } = true;
|
||||
public bool ParticipationAllowed { get; init; }
|
||||
public bool SubmissionClosed { get; init; }
|
||||
public bool ShowValidationErrors { get; init; }
|
||||
public string? ValidationError { get; init; }
|
||||
public bool Saved { get; init; }
|
||||
|
||||
public static ImmutableList<ImmutableList<string?>> CreateEmptyDistributions() =>
|
||||
Enumerable.Range(0, 3)
|
||||
.Select(_ => ImmutableList.CreateRange<string?>([null, null, null, null]))
|
||||
.ToImmutableList();
|
||||
}
|
||||
|
||||
public static class QuestionnaireSubmissionPolicy
|
||||
{
|
||||
private static readonly HashSet<string> AllowedClassrooms = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"FS25-8B",
|
||||
"FS26-8X",
|
||||
"FS26-8Y",
|
||||
"FSU26-8A",
|
||||
"absagen.spa"
|
||||
};
|
||||
|
||||
public static readonly DateTimeOffset Deadline = new(2026, 8, 25, 10, 0, 0, TimeSpan.FromHours(2));
|
||||
|
||||
public static bool IsClassroomAllowed(string? classroom) =>
|
||||
!string.IsNullOrWhiteSpace(classroom) && AllowedClassrooms.Contains(classroom);
|
||||
|
||||
public static bool IsSubmissionClosed(DateTimeOffset now) => now >= Deadline;
|
||||
|
||||
public static string ClosedMessage => "Die Umfrage wurde am Dienstag, 25. August 2026 um 10:00 MEST geschlossen.";
|
||||
public static string ClassroomNotAllowedMessage => "Ihre Klasse ist nicht berechtigt, an diesem Fragebogen teilzunehmen.";
|
||||
}
|
||||
|
||||
public static class QuestionnaireValidation
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Options = ["Heilpädagogik/Inklusion", "Erlebnispädagogik", "\"Debattieren\"", "Bewegung in der Kita", "Musikpädagogik", "BBNE/Gartenpädagogik"];
|
||||
|
||||
public static string? Validate(IReadOnlyList<ImmutableList<string?>> distributions)
|
||||
{
|
||||
if (distributions.Any(distribution => distribution.Any(string.IsNullOrWhiteSpace)))
|
||||
{
|
||||
return "Wählen Sie für jedes Halbjahr in allen drei Alternativen eine Option aus.";
|
||||
}
|
||||
|
||||
if (distributions.Any(distribution => distribution.Distinct(StringComparer.Ordinal).Count() != distribution.Count))
|
||||
{
|
||||
return "Eine Option kann innerhalb jeder Vier-Halbjahre-Alternative nur einmal verwendet werden.";
|
||||
}
|
||||
|
||||
if (distributions.Distinct(DistributionComparer.Instance).Count() != distributions.Count)
|
||||
{
|
||||
return "Jede Alternative muss eine unterschiedliche Halbjahresverteilung haben.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed class DistributionComparer : IEqualityComparer<ImmutableList<string?>>
|
||||
{
|
||||
public static DistributionComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals(ImmutableList<string?>? x, ImmutableList<string?>? y) =>
|
||||
ReferenceEquals(x, y) || (x is not null && y is not null && x.SequenceEqual(y, StringComparer.Ordinal));
|
||||
|
||||
public int GetHashCode(ImmutableList<string?> distribution)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
foreach (var option in distribution)
|
||||
{
|
||||
hash.Add(option, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Marten": "Host=lxc-db3;Port=5432;Database=oneforall_dev;Username=OneForAll;Password=ia9vgM6F9kUt",
|
||||
"Marten": "Host=lxc-db3;Port=5432;Database=wollmilchsau_dev;Username=wollmilchsau;Password=rVx0EoLDOnld",
|
||||
"RedisCache": "lxc-matrix:6379"
|
||||
},
|
||||
"OIDC": {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" role="img" aria-labelledby="title description">
|
||||
<title id="title">Administration</title>
|
||||
<desc id="description">Abstrakte Illustration eines Verwaltungsbereichs mit Schild und Einstellungen.</desc>
|
||||
<defs>
|
||||
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#7a3e16" />
|
||||
<stop offset="100%" stop-color="#b65b1c" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="400" fill="url(#background)" />
|
||||
<circle cx="1050" cy="55" r="145" fill="#ffffff" fill-opacity="0.08" />
|
||||
<circle cx="1150" cy="350" r="200" fill="#ffffff" fill-opacity="0.06" />
|
||||
<rect x="398" y="54" width="405" height="292" rx="24" fill="#ffffff" fill-opacity="0.96" />
|
||||
<path d="M575 107l88 31v70c0 56-37 92-88 116-51-24-88-60-88-116v-70l88-31z" fill="#b65b1c" />
|
||||
<path d="M537 208l25 25 53-61" fill="none" stroke="#ffffff" stroke-width="14" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="707" cy="169" r="31" fill="none" stroke="#4b6f91" stroke-width="12" />
|
||||
<path d="M707 116v16M707 206v16M654 169h16M744 169h16M669 131l11 11M734 196l11 11M745 131l-11 11M680 196l-11 11" stroke="#4b6f91" stroke-width="10" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" role="img" aria-labelledby="title description">
|
||||
<title id="title">Umfrageergebnisse</title>
|
||||
<desc id="description">Abstrakte Illustration eines Diagramms mit aufsteigenden Balken.</desc>
|
||||
<defs>
|
||||
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#4c3b8f" />
|
||||
<stop offset="100%" stop-color="#246aa0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="400" fill="url(#background)" />
|
||||
<circle cx="1050" cy="55" r="145" fill="#ffffff" fill-opacity="0.08" />
|
||||
<circle cx="1150" cy="350" r="200" fill="#ffffff" fill-opacity="0.06" />
|
||||
<rect x="398" y="54" width="405" height="292" rx="24" fill="#ffffff" fill-opacity="0.96" />
|
||||
<rect x="452" y="105" width="180" height="20" rx="10" fill="#4c3b8f" />
|
||||
<path d="M455 294V160M455 294h286" fill="none" stroke="#c8d8e6" stroke-width="8" stroke-linecap="round" />
|
||||
<rect x="495" y="225" width="44" height="69" rx="8" fill="#74c0e3" />
|
||||
<rect x="568" y="182" width="44" height="112" rx="8" fill="#5d78cb" />
|
||||
<rect x="641" y="137" width="44" height="157" rx="8" fill="#38a169" />
|
||||
<path d="M495 214l95-73 73 19 68-71" fill="none" stroke="#f0a34d" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="495" cy="214" r="9" fill="#f0a34d" />
|
||||
<circle cx="590" cy="141" r="9" fill="#f0a34d" />
|
||||
<circle cx="663" cy="160" r="9" fill="#f0a34d" />
|
||||
<circle cx="731" cy="89" r="9" fill="#f0a34d" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" role="img" aria-labelledby="title description">
|
||||
<title id="title">Umfragen</title>
|
||||
<desc id="description">Abstrakte Illustration eines Fragebogens mit Auswahlfeldern.</desc>
|
||||
<defs>
|
||||
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1b5e8c" />
|
||||
<stop offset="100%" stop-color="#254c8f" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="400" fill="url(#background)" />
|
||||
<circle cx="1060" cy="68" r="150" fill="#ffffff" fill-opacity="0.08" />
|
||||
<circle cx="1150" cy="350" r="190" fill="#ffffff" fill-opacity="0.06" />
|
||||
<rect x="390" y="54" width="420" height="292" rx="24" fill="#ffffff" fill-opacity="0.96" />
|
||||
<rect x="440" y="106" width="182" height="20" rx="10" fill="#1b5e8c" />
|
||||
<rect x="440" y="155" width="30" height="30" rx="8" fill="#38a169" />
|
||||
<path d="M448 170l9 9 17-19" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<rect x="492" y="160" width="244" height="18" rx="9" fill="#dce8f3" />
|
||||
<rect x="440" y="213" width="30" height="30" rx="15" fill="none" stroke="#7fa1ba" stroke-width="6" />
|
||||
<rect x="492" y="219" width="192" height="18" rx="9" fill="#dce8f3" />
|
||||
<rect x="440" y="271" width="30" height="30" rx="15" fill="none" stroke="#7fa1ba" stroke-width="6" />
|
||||
<rect x="492" y="277" width="222" height="18" rx="9" fill="#dce8f3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
Reference in New Issue
Block a user