Bugfixes and changes to the Admin Page
This commit is contained in:
@@ -5,17 +5,30 @@
|
|||||||
@inject Fluxor.IDispatcher Dispatcher
|
@inject Fluxor.IDispatcher Dispatcher
|
||||||
@inject Fluxor.IState<Wollmilchsau.Store.AdminStore.AdminState> AdminState
|
@inject Fluxor.IState<Wollmilchsau.Store.AdminStore.AdminState> AdminState
|
||||||
@inject IJSRuntime JsRuntime
|
@inject IJSRuntime JsRuntime
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
<PageTitle>User Comparison</PageTitle>
|
<PageTitle>User Comparison</PageTitle>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.moodle-delete-blocked > td {
|
||||||
|
background-color: var(--mud-palette-error-lighten, #ffebee) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<MudButton OnClick="DownloadCsvAsync" Disabled="@(!LdapUsers.Any())">Export CSV</MudButton>
|
<MudButton OnClick="DownloadCsvAsync" Disabled="@(!LdapUsers.Any())">Export CSV</MudButton>
|
||||||
|
<MudButton OnClick="RefreshMoodleUsers">Refresh Moodle users</MudButton>
|
||||||
|
<MudButton OnClick="RefreshLdapUsers">Refresh LDAP users</MudButton>
|
||||||
|
<MudButton Color="Color.Error" OnClick="DeleteSelectedMoodleUsers"
|
||||||
|
Disabled="@(!SelectedUsers.Any() || AdminState.Value.MoodleUsersDeletionInProgress)">
|
||||||
|
Delete selected Moodle users
|
||||||
|
</MudButton>
|
||||||
|
|
||||||
<MudText Typo="Typo.h1">User Comparison</MudText>
|
<MudText Typo="Typo.h1">User Comparison</MudText>
|
||||||
|
|
||||||
@if (!AdminState.Value.LdapUsersLoaded || !AdminState.Value.MoodleUsersLoaded)
|
@if (!AdminState.Value.LdapUsersLoaded || !AdminState.Value.MoodleUsersLoaded)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
|
||||||
Loading @string.Join(" and ", PendingUserImports, " users")...
|
Loading @string.Join(" and ", PendingUserImports)...
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -27,6 +40,13 @@ else
|
|||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (AdminState.Value.MoodleUsersDeletionInProgress)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
|
||||||
|
Deleting Moodle users: @AdminState.Value.MoodleUsersDeletedCount of @AdminState.Value.MoodleUsersDeletionTotalCount deleted...
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
@if (!CombinedUsers.Any())
|
@if (!CombinedUsers.Any())
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
|
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
|
||||||
@@ -35,8 +55,14 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudTable T="CombinedUser" Items="@CombinedUsers" Hover="true" Dense="true" RowsPerPage="25">
|
<MudTable @ref="UserTable" T="CombinedUser" Items="@CombinedUsers" Hover="true" Dense="true" RowsPerPage="25"
|
||||||
|
MultiSelection="true" @bind-SelectedItems="SelectedUsers" RowClassFunc="GetRowClass" CustomHeader="true">
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
|
<MudTHeadRow IgnoreCheckbox="true">
|
||||||
|
<MudTh>
|
||||||
|
<MudCheckBox T="bool?" Value="CurrentPageSelectionState" ValueChanged="SetCurrentPageSelection"
|
||||||
|
aria-label="Select visible users" />
|
||||||
|
</MudTh>
|
||||||
<MudTh><MudTableSortLabel SortBy="SortByUsername">Username</MudTableSortLabel></MudTh>
|
<MudTh><MudTableSortLabel SortBy="SortByUsername">Username</MudTableSortLabel></MudTh>
|
||||||
<MudTh><MudTableSortLabel SortBy="SortByClassroom">Classroom</MudTableSortLabel></MudTh>
|
<MudTh><MudTableSortLabel SortBy="SortByClassroom">Classroom</MudTableSortLabel></MudTh>
|
||||||
<MudTh><MudTableSortLabel SortBy="SortBySurname">Surname</MudTableSortLabel></MudTh>
|
<MudTh><MudTableSortLabel SortBy="SortBySurname">Surname</MudTableSortLabel></MudTh>
|
||||||
@@ -44,6 +70,7 @@ else
|
|||||||
<MudTh><MudTableSortLabel SortBy="SortByRole">Role</MudTableSortLabel></MudTh>
|
<MudTh><MudTableSortLabel SortBy="SortByRole">Role</MudTableSortLabel></MudTh>
|
||||||
<MudTh><MudTableSortLabel SortBy="SortByLdapExistence">Exists in LDAP</MudTableSortLabel></MudTh>
|
<MudTh><MudTableSortLabel SortBy="SortByLdapExistence">Exists in LDAP</MudTableSortLabel></MudTh>
|
||||||
<MudTh><MudTableSortLabel SortBy="SortByMoodleExistence">Exists in Moodle</MudTableSortLabel></MudTh>
|
<MudTh><MudTableSortLabel SortBy="SortByMoodleExistence">Exists in Moodle</MudTableSortLabel></MudTh>
|
||||||
|
</MudTHeadRow>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="Username">@context.Username</MudTd>
|
<MudTd DataLabel="Username">@context.Username</MudTd>
|
||||||
@@ -70,8 +97,40 @@ else
|
|||||||
private static readonly Func<CombinedUser, object> SortByLdapExistence = user => user.ExistsInLdap;
|
private static readonly Func<CombinedUser, object> SortByLdapExistence = user => user.ExistsInLdap;
|
||||||
private static readonly Func<CombinedUser, object> SortByMoodleExistence = user => user.ExistsInMoodle;
|
private static readonly Func<CombinedUser, object> SortByMoodleExistence = user => user.ExistsInMoodle;
|
||||||
|
|
||||||
|
private MudTable<CombinedUser>? UserTable { get; set; }
|
||||||
|
private HashSet<CombinedUser> SelectedUsers { get; set; } = [];
|
||||||
|
private HashSet<CombinedUser> InvalidDeleteSelection { get; } = [];
|
||||||
private IEnumerable<Wollmilchsau.Models.LdapUser> LdapUsers => AdminState.Value.LdapUsers.Values;
|
private IEnumerable<Wollmilchsau.Models.LdapUser> LdapUsers => AdminState.Value.LdapUsers.Values;
|
||||||
|
|
||||||
|
private IEnumerable<CombinedUser> CurrentPageUsers
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (UserTable is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserTable.FilteredItems
|
||||||
|
.Skip(UserTable.CurrentPage * UserTable.RowsPerPage)
|
||||||
|
.Take(UserTable.RowsPerPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool? CurrentPageSelectionState
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var users = CurrentPageUsers.ToArray();
|
||||||
|
if (users.Length == 0 || !users.Any(SelectedUsers.Contains))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return users.All(SelectedUsers.Contains) ? true : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private IEnumerable<string> PendingUserImports
|
private IEnumerable<string> PendingUserImports
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -123,25 +182,101 @@ else
|
|||||||
private Task DownloadCsvAsync()
|
private Task DownloadCsvAsync()
|
||||||
{
|
{
|
||||||
var csv = string.Join("\r\n", LdapUsers
|
var csv = string.Join("\r\n", LdapUsers
|
||||||
.Where(user => user.Role == Wollmilchsau.Models.LdapUserRole.Student)
|
.Where(user =>
|
||||||
|
user.Role == Wollmilchsau.Models.LdapUserRole.Student &&
|
||||||
|
!string.IsNullOrWhiteSpace(user.Classroom))
|
||||||
.Select(user => string.Join(';',
|
.Select(user => string.Join(';',
|
||||||
user.GivenName ?? string.Empty,
|
user.GivenName ?? string.Empty,
|
||||||
user.Surname ?? string.Empty,
|
user.Surname ?? string.Empty,
|
||||||
user.Classroom ?? string.Empty,
|
user.Classroom,
|
||||||
user.Username)));
|
user.Username)));
|
||||||
|
|
||||||
return JsRuntime.InvokeVoidAsync("downloadTextFile", "ldap-users.csv", csv).AsTask();
|
return JsRuntime.InvokeVoidAsync("downloadTextFile", "ldap-users.csv", csv).AsTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RefreshMoodleUsers()
|
||||||
|
{
|
||||||
|
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.RefreshMoodleUsers());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshLdapUsers()
|
||||||
|
{
|
||||||
|
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.RefreshLdapUsers());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetCurrentPageSelection(bool? selected)
|
||||||
|
{
|
||||||
|
var users = CurrentPageUsers.ToArray();
|
||||||
|
if (selected == true)
|
||||||
|
{
|
||||||
|
SelectedUsers.UnionWith(users);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SelectedUsers.ExceptWith(users);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteSelectedMoodleUsers()
|
||||||
|
{
|
||||||
|
InvalidDeleteSelection.Clear();
|
||||||
|
|
||||||
|
var protectedUsers = SelectedUsers
|
||||||
|
.Where(user => user.ExistsInLdap || ContainsAdminTerm(user) || IsManualMoodleUser(user))
|
||||||
|
.ToArray();
|
||||||
|
if (protectedUsers.Length > 0)
|
||||||
|
{
|
||||||
|
InvalidDeleteSelection.UnionWith(protectedUsers);
|
||||||
|
Snackbar.Add("No users were deleted because the selection contains LDAP users, manual Moodle users, or names containing 'admin'.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var moodleUserIds = SelectedUsers
|
||||||
|
.Where(user => user.ExistsInMoodle && user.MoodleUserId is not null)
|
||||||
|
.Select(user => user.MoodleUserId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (moodleUserIds.Length == 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add("No Moodle users were selected for deletion.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.DeleteMoodleUsers(moodleUserIds));
|
||||||
|
SelectedUsers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetRowClass(CombinedUser user, int rowIndex)
|
||||||
|
{
|
||||||
|
return InvalidDeleteSelection.Contains(user) ? "moodle-delete-blocked" : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAdminTerm(CombinedUser user)
|
||||||
|
{
|
||||||
|
return user.Username.Contains("admin", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
user.Surname?.Contains("admin", StringComparison.OrdinalIgnoreCase) == true ||
|
||||||
|
user.GivenName?.Contains("admin", StringComparison.OrdinalIgnoreCase) == true ||
|
||||||
|
user.MoodleFullname?.Contains("admin", StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsManualMoodleUser(CombinedUser user)
|
||||||
|
{
|
||||||
|
return string.Equals(user.MoodleAuth, "manual", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private static CombinedUser CreateCombinedUser(
|
private static CombinedUser CreateCombinedUser(
|
||||||
Wollmilchsau.Models.LdapUser? ldapUser,
|
Wollmilchsau.Models.LdapUser? ldapUser,
|
||||||
Wollmilchsau.Models.MoodleUser? moodleUser)
|
Wollmilchsau.Models.MoodleUser? moodleUser)
|
||||||
{
|
{
|
||||||
return new CombinedUser(
|
return new CombinedUser(
|
||||||
ldapUser?.Username ?? moodleUser?.Username ?? string.Empty,
|
ldapUser?.Username ?? moodleUser?.Username ?? string.Empty,
|
||||||
|
moodleUser?.Id,
|
||||||
ldapUser?.Classroom,
|
ldapUser?.Classroom,
|
||||||
ldapUser?.Surname ?? moodleUser?.Lastname,
|
ldapUser?.Surname ?? moodleUser?.Lastname,
|
||||||
ldapUser?.GivenName ?? moodleUser?.Firstname,
|
ldapUser?.GivenName ?? moodleUser?.Firstname,
|
||||||
|
moodleUser?.Fullname,
|
||||||
|
moodleUser?.Auth,
|
||||||
ldapUser?.Role,
|
ldapUser?.Role,
|
||||||
ldapUser is not null,
|
ldapUser is not null,
|
||||||
moodleUser is not null);
|
moodleUser is not null);
|
||||||
@@ -149,9 +284,12 @@ else
|
|||||||
|
|
||||||
private sealed record CombinedUser(
|
private sealed record CombinedUser(
|
||||||
string Username,
|
string Username,
|
||||||
|
int? MoodleUserId,
|
||||||
string? Classroom,
|
string? Classroom,
|
||||||
string? Surname,
|
string? Surname,
|
||||||
string? GivenName,
|
string? GivenName,
|
||||||
|
string? MoodleFullname,
|
||||||
|
string? MoodleAuth,
|
||||||
Wollmilchsau.Models.LdapUserRole? Role,
|
Wollmilchsau.Models.LdapUserRole? Role,
|
||||||
bool ExistsInLdap,
|
bool ExistsInLdap,
|
||||||
bool ExistsInMoodle);
|
bool ExistsInMoodle);
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ builder.Services.AddHttpClient<IMoodleService, MoodleService>((serviceProvider,
|
|||||||
{
|
{
|
||||||
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<MoodleOptions>>().Value;
|
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<MoodleOptions>>().Value;
|
||||||
client.BaseAddress = new Uri(options.BaseUrl, UriKind.Absolute);
|
client.BaseAddress = new Uri(options.BaseUrl, UriKind.Absolute);
|
||||||
client.Timeout = TimeSpan.FromSeconds(30);
|
client.Timeout = TimeSpan.FromMinutes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
|
|||||||
SearchScope.Subtree,
|
SearchScope.Subtree,
|
||||||
"uid",
|
"uid",
|
||||||
"sn",
|
"sn",
|
||||||
"departmentNumber",
|
|
||||||
"givenName",
|
"givenName",
|
||||||
"memberOf");
|
"memberOf");
|
||||||
var response = (SearchResponse)connection.SendRequest(request);
|
var response = (SearchResponse)connection.SendRequest(request);
|
||||||
@@ -129,7 +128,7 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
|
|||||||
{
|
{
|
||||||
var username = GetFirstAttributeValue(entry, "uid") ?? throw new InvalidOperationException($"LDAP user entry '{entry.DistinguishedName}' is missing uid.");
|
var username = GetFirstAttributeValue(entry, "uid") ?? throw new InvalidOperationException($"LDAP user entry '{entry.DistinguishedName}' is missing uid.");
|
||||||
var surname = CapitalizeFirstLetter(GetFirstAttributeValue(entry, "sn"));
|
var surname = CapitalizeFirstLetter(GetFirstAttributeValue(entry, "sn"));
|
||||||
var classroom = GetFirstAttributeValue(entry, "departmentNumber");
|
var classroom = GetClassroomFromMembership(entry);
|
||||||
var givenName = GetFirstAttributeValue(entry, "givenName");
|
var givenName = GetFirstAttributeValue(entry, "givenName");
|
||||||
var role = DetermineUserRole(entry, studentRoleDn, teacherRoleDn);
|
var role = DetermineUserRole(entry, studentRoleDn, teacherRoleDn);
|
||||||
|
|
||||||
@@ -144,6 +143,17 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
|
|||||||
role);
|
role);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? GetClassroomFromMembership(SearchResultEntry entry)
|
||||||
|
{
|
||||||
|
const string classroomGroupPrefix = "CN=klasse.";
|
||||||
|
|
||||||
|
return GetAttributeValues(entry, "memberOf")
|
||||||
|
.Select(groupDn => groupDn.Split(',', 2)[0])
|
||||||
|
.Where(commonName => commonName.StartsWith(classroomGroupPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(commonName => commonName[classroomGroupPrefix.Length..].ToUpperInvariant())
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
private static LdapUserRole DetermineUserRole(SearchResultEntry entry, string studentRoleDn, string teacherRoleDn)
|
private static LdapUserRole DetermineUserRole(SearchResultEntry entry, string studentRoleDn, string teacherRoleDn)
|
||||||
{
|
{
|
||||||
var roles = GetAttributeValues(entry, "memberOf");
|
var roles = GetAttributeValues(entry, "memberOf");
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ namespace Wollmilchsau.Services.Moodle;
|
|||||||
public interface IMoodleService
|
public interface IMoodleService
|
||||||
{
|
{
|
||||||
Task<IReadOnlyCollection<MoodleUser>> GetAllUsers(CancellationToken cancellationToken = default);
|
Task<IReadOnlyCollection<MoodleUser>> GetAllUsers(CancellationToken cancellationToken = default);
|
||||||
|
Task DeleteUsers(IReadOnlyCollection<int> userIds, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.AspNetCore.Http.Extensions;
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Wollmilchsau.Models;
|
using Wollmilchsau.Models;
|
||||||
@@ -39,9 +40,43 @@ public sealed class MoodleService(HttpClient httpClient, IOptions<MoodleOptions>
|
|||||||
return result.Users ?? throw new InvalidOperationException("Moodle user search response did not contain users.");
|
return result.Users ?? throw new InvalidOperationException("Moodle user search response did not contain users.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteUsers(IReadOnlyCollection<int> userIds, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (userIds.Count == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("At least one Moodle user ID is required.", nameof(userIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = new QueryBuilder
|
||||||
|
{
|
||||||
|
{ "wstoken", _options.WsToken },
|
||||||
|
{ "wsfunction", "core_user_delete_users" },
|
||||||
|
{ "moodlewsrestformat", "json" }
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (userId, index) in userIds.Distinct().Select((userId, index) => (userId, index)))
|
||||||
|
{
|
||||||
|
query.Add($"userids[{index}]", userId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await httpClient.GetAsync(query.ToQueryString().Value, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<MoodleDeletionResponse>(cancellationToken);
|
||||||
|
if (!string.IsNullOrWhiteSpace(result?.Exception))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Moodle user deletion failed with {result.ErrorCode ?? "an unspecified error"}: {result.Message ?? result.Exception}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record MoodleUserSearchResponse(
|
private sealed record MoodleUserSearchResponse(
|
||||||
IReadOnlyCollection<MoodleUser>? Users,
|
IReadOnlyCollection<MoodleUser>? Users,
|
||||||
string? Exception,
|
string? Exception,
|
||||||
string? ErrorCode,
|
string? ErrorCode,
|
||||||
string? Message);
|
string? Message);
|
||||||
|
|
||||||
|
private sealed record MoodleDeletionResponse(
|
||||||
|
string? Exception,
|
||||||
|
string? ErrorCode,
|
||||||
|
string? Message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,17 @@
|
|||||||
namespace Wollmilchsau.Store.AdminStore;
|
namespace Wollmilchsau.Store.AdminStore;
|
||||||
|
|
||||||
public sealed record GetAllMoodleUsers();
|
public sealed record GetAllMoodleUsers();
|
||||||
|
public sealed record RefreshMoodleUsers();
|
||||||
public sealed record SetMoodleUsers(IReadOnlyCollection<MoodleUser> Users);
|
public sealed record SetMoodleUsers(IReadOnlyCollection<MoodleUser> Users);
|
||||||
public sealed record SetMoodleUsersLoadFailed();
|
public sealed record SetMoodleUsersLoadFailed();
|
||||||
|
public sealed record DeleteMoodleUsers(IReadOnlyCollection<int> UserIds);
|
||||||
|
public sealed record MoodleUsersDeletionStarted(int TotalUsers);
|
||||||
|
public sealed record MoodleUsersDeleted(IReadOnlyCollection<int> UserIds);
|
||||||
|
public sealed record MoodleUsersDeletionFailed();
|
||||||
|
public sealed record MoodleUsersDeletionCompleted();
|
||||||
public sealed record GetAllElementUsers();
|
public sealed record GetAllElementUsers();
|
||||||
public sealed record GetAllLdapUsers();
|
public sealed record GetAllLdapUsers();
|
||||||
|
public sealed record RefreshLdapUsers();
|
||||||
public sealed record SetLdapUsers(IReadOnlyCollection<LdapUser> Users);
|
public sealed record SetLdapUsers(IReadOnlyCollection<LdapUser> Users);
|
||||||
public sealed record GetAllClassrooms();
|
public sealed record GetAllClassrooms();
|
||||||
public sealed record SetClassrooms(IReadOnlyCollection<Classroom> Classrooms);
|
public sealed record SetClassrooms(IReadOnlyCollection<Classroom> Classrooms);
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Fluxor;
|
using Fluxor;
|
||||||
using Microsoft.Extensions.Caching.Distributed;
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
|
using MudBlazor;
|
||||||
|
using StackExchange.Redis;
|
||||||
using Wollmilchsau.Models;
|
using Wollmilchsau.Models;
|
||||||
using Wollmilchsau.Services.Ldap;
|
using Wollmilchsau.Services.Ldap;
|
||||||
using Wollmilchsau.Services.Moodle;
|
using Wollmilchsau.Services.Moodle;
|
||||||
|
|
||||||
namespace Wollmilchsau.Store.AdminStore;
|
namespace Wollmilchsau.Store.AdminStore;
|
||||||
|
|
||||||
public sealed class AdminEffects(IMoodleService moodleService, ILdapService ldapService, IDistributedCache cache)
|
public sealed class AdminEffects(IMoodleService moodleService, ILdapService ldapService, IDistributedCache cache, ISnackbar snackbar)
|
||||||
{
|
{
|
||||||
|
private const int MoodleDeleteBatchSize = 5;
|
||||||
private const string MoodleUsersCacheKey = "admin:moodle-users";
|
private const string MoodleUsersCacheKey = "admin:moodle-users";
|
||||||
private const string LdapUsersCacheKey = "admin:ldap-users";
|
private const string LdapUsersCacheKey = "admin:ldap-users";
|
||||||
private const string ClassroomsCacheKey = "admin:classrooms";
|
private const string ClassroomsCacheKey = "admin:classrooms";
|
||||||
@@ -74,6 +77,124 @@ public sealed class AdminEffects(IMoodleService moodleService, ILdapService ldap
|
|||||||
return users;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[EffectMethod]
|
||||||
|
public async Task HandleRefreshMoodleUsers(RefreshMoodleUsers action, IDispatcher dispatcher)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await cache.RemoveAsync(MoodleUsersCacheKey);
|
||||||
|
dispatcher.Dispatch(new SetMoodleUsers(await LoadAndCacheMoodleUsers()));
|
||||||
|
snackbar.Add("Moodle users refreshed.", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (RedisException)
|
||||||
|
{
|
||||||
|
snackbar.Add("Moodle users could not be refreshed because the cache could not be cleared.", Severity.Error);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
snackbar.Add("Moodle users could not be refreshed.", Severity.Error);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
snackbar.Add("Moodle users could not be refreshed.", Severity.Error);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
snackbar.Add("Moodle users could not be refreshed.", Severity.Error);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
snackbar.Add("Moodle user refresh timed out.", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[EffectMethod]
|
||||||
|
public async Task HandleDeleteMoodleUsers(DeleteMoodleUsers action, IDispatcher dispatcher)
|
||||||
|
{
|
||||||
|
var batches = action.UserIds
|
||||||
|
.Distinct()
|
||||||
|
.Chunk(MoodleDeleteBatchSize)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (batches.Length == 0)
|
||||||
|
{
|
||||||
|
snackbar.Add("No Moodle users were selected for deletion.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalUserCount = batches.Sum(batch => batch.Length);
|
||||||
|
dispatcher.Dispatch(new MoodleUsersDeletionStarted(totalUserCount));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await cache.RemoveAsync(MoodleUsersCacheKey);
|
||||||
|
}
|
||||||
|
catch (RedisException)
|
||||||
|
{
|
||||||
|
ReportMoodleDeletionFailure(dispatcher, "Moodle deletion did not start because the cache could not be invalidated.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedUserCount = 0;
|
||||||
|
foreach (var batch in batches)
|
||||||
|
{
|
||||||
|
if (!await TryDeleteMoodleUserBatch(batch, deletedUserCount, totalUserCount, dispatcher))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatcher.Dispatch(new MoodleUsersDeleted(batch));
|
||||||
|
deletedUserCount += batch.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatcher.Dispatch(new MoodleUsersDeletionCompleted());
|
||||||
|
snackbar.Add($"Deleted {action.UserIds.Distinct().Count()} Moodle user(s).", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryDeleteMoodleUserBatch(
|
||||||
|
IReadOnlyCollection<int> userIds,
|
||||||
|
int deletedUserCount,
|
||||||
|
int totalUserCount,
|
||||||
|
IDispatcher dispatcher)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await moodleService.DeleteUsers(userIds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
ReportMoodleDeletionFailure(dispatcher, deletedUserCount, totalUserCount);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
ReportMoodleDeletionFailure(dispatcher, deletedUserCount, totalUserCount);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
ReportMoodleDeletionFailure(dispatcher, deletedUserCount, totalUserCount);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
ReportMoodleDeletionFailure(dispatcher, deletedUserCount, totalUserCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReportMoodleDeletionFailure(IDispatcher dispatcher, int deletedUserCount, int totalUserCount)
|
||||||
|
{
|
||||||
|
ReportMoodleDeletionFailure(
|
||||||
|
dispatcher,
|
||||||
|
$"Moodle deletion stopped after deleting {deletedUserCount} of {totalUserCount} user(s). Later users were not processed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReportMoodleDeletionFailure(IDispatcher dispatcher, string message)
|
||||||
|
{
|
||||||
|
dispatcher.Dispatch(new MoodleUsersDeletionFailed());
|
||||||
|
snackbar.Add(message, Severity.Error);
|
||||||
|
}
|
||||||
|
|
||||||
[EffectMethod]
|
[EffectMethod]
|
||||||
public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher)
|
public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher)
|
||||||
{
|
{
|
||||||
@@ -121,6 +242,30 @@ public sealed class AdminEffects(IMoodleService moodleService, ILdapService ldap
|
|||||||
return loadedUsers;
|
return loadedUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[EffectMethod]
|
||||||
|
public async Task HandleRefreshLdapUsers(RefreshLdapUsers action, IDispatcher dispatcher)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await cache.RemoveAsync(LdapUsersCacheKey);
|
||||||
|
var users = await LoadAndCacheLdapUsers(await ldapService.GetUserCount());
|
||||||
|
dispatcher.Dispatch(new SetLdapUsers(users));
|
||||||
|
snackbar.Add("LDAP users refreshed.", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (RedisException)
|
||||||
|
{
|
||||||
|
snackbar.Add("LDAP users could not be refreshed because the cache could not be cleared.", Severity.Error);
|
||||||
|
}
|
||||||
|
catch (System.DirectoryServices.Protocols.LdapException)
|
||||||
|
{
|
||||||
|
snackbar.Add("LDAP users could not be refreshed.", Severity.Error);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
snackbar.Add("LDAP users could not be refreshed.", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[EffectMethod]
|
[EffectMethod]
|
||||||
public async Task HandleGetAllClassrooms(GetAllClassrooms action, IDispatcher dispatcher)
|
public async Task HandleGetAllClassrooms(GetAllClassrooms action, IDispatcher dispatcher)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ public static class AdminReducers
|
|||||||
{
|
{
|
||||||
MoodleUsers = action.Users.ToImmutableDictionary(user => user.Id),
|
MoodleUsers = action.Users.ToImmutableDictionary(user => user.Id),
|
||||||
MoodleUsersLoaded = true,
|
MoodleUsersLoaded = true,
|
||||||
MoodleUsersLoadFailed = false
|
MoodleUsersLoadFailed = false,
|
||||||
|
MoodleUsersDeletionInProgress = false,
|
||||||
|
MoodleUsersDeletedCount = 0,
|
||||||
|
MoodleUsersDeletionTotalCount = 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +30,44 @@ public static class AdminReducers
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ReducerMethod]
|
||||||
|
public static AdminState ReduceMoodleUsersDeletionStarted(AdminState state, MoodleUsersDeletionStarted action)
|
||||||
|
{
|
||||||
|
return state with
|
||||||
|
{
|
||||||
|
MoodleUsersDeletionInProgress = true,
|
||||||
|
MoodleUsersDeletedCount = 0,
|
||||||
|
MoodleUsersDeletionTotalCount = action.TotalUsers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[ReducerMethod]
|
||||||
|
public static AdminState ReduceMoodleUsersDeleted(AdminState state, MoodleUsersDeleted action)
|
||||||
|
{
|
||||||
|
return state with
|
||||||
|
{
|
||||||
|
MoodleUsers = state.MoodleUsers.RemoveRange(action.UserIds),
|
||||||
|
MoodleUsersDeletedCount = state.MoodleUsersDeletedCount + action.UserIds.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[ReducerMethod]
|
||||||
|
public static AdminState ReduceMoodleUsersDeletionFailed(AdminState state, MoodleUsersDeletionFailed action)
|
||||||
|
{
|
||||||
|
return state with { MoodleUsersDeletionInProgress = false };
|
||||||
|
}
|
||||||
|
|
||||||
|
[ReducerMethod]
|
||||||
|
public static AdminState ReduceMoodleUsersDeletionCompleted(AdminState state, MoodleUsersDeletionCompleted action)
|
||||||
|
{
|
||||||
|
return state with
|
||||||
|
{
|
||||||
|
MoodleUsersDeletionInProgress = false,
|
||||||
|
MoodleUsersDeletedCount = 0,
|
||||||
|
MoodleUsersDeletionTotalCount = 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
[ReducerMethod]
|
[ReducerMethod]
|
||||||
public static AdminState ReduceSetLdapUsers(AdminState state, SetLdapUsers action)
|
public static AdminState ReduceSetLdapUsers(AdminState state, SetLdapUsers action)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ public sealed record AdminState
|
|||||||
public ImmutableDictionary<int, MoodleUser> MoodleUsers { get; init; } = [];
|
public ImmutableDictionary<int, MoodleUser> MoodleUsers { get; init; } = [];
|
||||||
public bool MoodleUsersLoaded { get; init; }
|
public bool MoodleUsersLoaded { get; init; }
|
||||||
public bool MoodleUsersLoadFailed { get; init; }
|
public bool MoodleUsersLoadFailed { get; init; }
|
||||||
|
public bool MoodleUsersDeletionInProgress { get; init; }
|
||||||
|
public int MoodleUsersDeletedCount { get; init; }
|
||||||
|
public int MoodleUsersDeletionTotalCount { get; init; }
|
||||||
public ImmutableDictionary<string, LdapUser> LdapUsers { get; init; } = [];
|
public ImmutableDictionary<string, LdapUser> LdapUsers { get; init; } = [];
|
||||||
public ImmutableList<Classroom> Classrooms { get; init; } = [];
|
public ImmutableList<Classroom> Classrooms { get; init; } = [];
|
||||||
public bool LdapUsersLoaded { get; init; }
|
public bool LdapUsersLoaded { get; init; }
|
||||||
|
|||||||
Reference in New Issue
Block a user