diff --git a/Wollmilchsau/Components/Pages/Admin/AdminHome.razor b/Wollmilchsau/Components/Pages/Admin/AdminHome.razor index e1e3a63..5099f61 100644 --- a/Wollmilchsau/Components/Pages/Admin/AdminHome.razor +++ b/Wollmilchsau/Components/Pages/Admin/AdminHome.razor @@ -5,17 +5,30 @@ @inject Fluxor.IDispatcher Dispatcher @inject Fluxor.IState AdminState @inject IJSRuntime JsRuntime +@inject ISnackbar Snackbar User Comparison + + Export CSV +Refresh Moodle users +Refresh LDAP users + + Delete selected Moodle users + User Comparison @if (!AdminState.Value.LdapUsersLoaded || !AdminState.Value.MoodleUsersLoaded) { - Loading @string.Join(" and ", PendingUserImports, " users")... + Loading @string.Join(" and ", PendingUserImports)... } else @@ -27,6 +40,13 @@ else } + @if (AdminState.Value.MoodleUsersDeletionInProgress) + { + + Deleting Moodle users: @AdminState.Value.MoodleUsersDeletedCount of @AdminState.Value.MoodleUsersDeletionTotalCount deleted... + + } + @if (!CombinedUsers.Any()) { @@ -35,15 +55,22 @@ else } else { - + - Username - Classroom - Surname - Given name - Role - Exists in LDAP - Exists in Moodle + + + + + Username + Classroom + Surname + Given name + Role + Exists in LDAP + Exists in Moodle + @context.Username @@ -70,8 +97,40 @@ else private static readonly Func SortByLdapExistence = user => user.ExistsInLdap; private static readonly Func SortByMoodleExistence = user => user.ExistsInMoodle; + private MudTable? UserTable { get; set; } + private HashSet SelectedUsers { get; set; } = []; + private HashSet InvalidDeleteSelection { get; } = []; private IEnumerable LdapUsers => AdminState.Value.LdapUsers.Values; + private IEnumerable 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 PendingUserImports { get @@ -123,25 +182,101 @@ else private Task DownloadCsvAsync() { 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(';', user.GivenName ?? string.Empty, user.Surname ?? string.Empty, - user.Classroom ?? string.Empty, + user.Classroom, user.Username))); 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( Wollmilchsau.Models.LdapUser? ldapUser, Wollmilchsau.Models.MoodleUser? moodleUser) { return new CombinedUser( ldapUser?.Username ?? moodleUser?.Username ?? string.Empty, + moodleUser?.Id, ldapUser?.Classroom, ldapUser?.Surname ?? moodleUser?.Lastname, ldapUser?.GivenName ?? moodleUser?.Firstname, + moodleUser?.Fullname, + moodleUser?.Auth, ldapUser?.Role, ldapUser is not null, moodleUser is not null); @@ -149,9 +284,12 @@ else private sealed record CombinedUser( string Username, + int? MoodleUserId, string? Classroom, string? Surname, string? GivenName, + string? MoodleFullname, + string? MoodleAuth, Wollmilchsau.Models.LdapUserRole? Role, bool ExistsInLdap, bool ExistsInMoodle); diff --git a/Wollmilchsau/Program.cs b/Wollmilchsau/Program.cs index 0b58d7d..8eba3f7 100644 --- a/Wollmilchsau/Program.cs +++ b/Wollmilchsau/Program.cs @@ -99,7 +99,7 @@ builder.Services.AddHttpClient((serviceProvider, { var options = serviceProvider.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl, UriKind.Absolute); - client.Timeout = TimeSpan.FromSeconds(30); + client.Timeout = TimeSpan.FromMinutes(2); }); // Add services to the container. diff --git a/Wollmilchsau/Services/Ldap/LdapService.cs b/Wollmilchsau/Services/Ldap/LdapService.cs index 1a2c042..c8c9cae 100644 --- a/Wollmilchsau/Services/Ldap/LdapService.cs +++ b/Wollmilchsau/Services/Ldap/LdapService.cs @@ -115,7 +115,6 @@ public sealed class LdapService(IOptions options) : ILdapService SearchScope.Subtree, "uid", "sn", - "departmentNumber", "givenName", "memberOf"); var response = (SearchResponse)connection.SendRequest(request); @@ -129,7 +128,7 @@ public sealed class LdapService(IOptions options) : ILdapService { var username = GetFirstAttributeValue(entry, "uid") ?? throw new InvalidOperationException($"LDAP user entry '{entry.DistinguishedName}' is missing uid."); var surname = CapitalizeFirstLetter(GetFirstAttributeValue(entry, "sn")); - var classroom = GetFirstAttributeValue(entry, "departmentNumber"); + var classroom = GetClassroomFromMembership(entry); var givenName = GetFirstAttributeValue(entry, "givenName"); var role = DetermineUserRole(entry, studentRoleDn, teacherRoleDn); @@ -144,6 +143,17 @@ public sealed class LdapService(IOptions options) : ILdapService 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) { var roles = GetAttributeValues(entry, "memberOf"); diff --git a/Wollmilchsau/Services/Moodle/IMoodleService.cs b/Wollmilchsau/Services/Moodle/IMoodleService.cs index 792fb19..00755b8 100644 --- a/Wollmilchsau/Services/Moodle/IMoodleService.cs +++ b/Wollmilchsau/Services/Moodle/IMoodleService.cs @@ -5,4 +5,5 @@ namespace Wollmilchsau.Services.Moodle; public interface IMoodleService { Task> GetAllUsers(CancellationToken cancellationToken = default); + Task DeleteUsers(IReadOnlyCollection userIds, CancellationToken cancellationToken = default); } diff --git a/Wollmilchsau/Services/Moodle/MoodleService.cs b/Wollmilchsau/Services/Moodle/MoodleService.cs index 3af5a12..0c5e39c 100644 --- a/Wollmilchsau/Services/Moodle/MoodleService.cs +++ b/Wollmilchsau/Services/Moodle/MoodleService.cs @@ -1,4 +1,5 @@ using System.Net.Http.Json; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Options; using Wollmilchsau.Models; @@ -39,9 +40,43 @@ public sealed class MoodleService(HttpClient httpClient, IOptions return result.Users ?? throw new InvalidOperationException("Moodle user search response did not contain users."); } + public async Task DeleteUsers(IReadOnlyCollection 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(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( IReadOnlyCollection? Users, string? Exception, string? ErrorCode, string? Message); + + private sealed record MoodleDeletionResponse( + string? Exception, + string? ErrorCode, + string? Message); } diff --git a/Wollmilchsau/Store/AdminStore/AdminActions.cs b/Wollmilchsau/Store/AdminStore/AdminActions.cs index 2ce5bd9..855e2a9 100644 --- a/Wollmilchsau/Store/AdminStore/AdminActions.cs +++ b/Wollmilchsau/Store/AdminStore/AdminActions.cs @@ -3,10 +3,17 @@ namespace Wollmilchsau.Store.AdminStore; public sealed record GetAllMoodleUsers(); +public sealed record RefreshMoodleUsers(); public sealed record SetMoodleUsers(IReadOnlyCollection Users); public sealed record SetMoodleUsersLoadFailed(); +public sealed record DeleteMoodleUsers(IReadOnlyCollection UserIds); +public sealed record MoodleUsersDeletionStarted(int TotalUsers); +public sealed record MoodleUsersDeleted(IReadOnlyCollection UserIds); +public sealed record MoodleUsersDeletionFailed(); +public sealed record MoodleUsersDeletionCompleted(); public sealed record GetAllElementUsers(); public sealed record GetAllLdapUsers(); +public sealed record RefreshLdapUsers(); public sealed record SetLdapUsers(IReadOnlyCollection Users); public sealed record GetAllClassrooms(); public sealed record SetClassrooms(IReadOnlyCollection Classrooms); diff --git a/Wollmilchsau/Store/AdminStore/AdminEffects.cs b/Wollmilchsau/Store/AdminStore/AdminEffects.cs index d9cadea..22aa723 100644 --- a/Wollmilchsau/Store/AdminStore/AdminEffects.cs +++ b/Wollmilchsau/Store/AdminStore/AdminEffects.cs @@ -1,14 +1,17 @@ using System.Text.Json; using Fluxor; using Microsoft.Extensions.Caching.Distributed; +using MudBlazor; +using StackExchange.Redis; using Wollmilchsau.Models; using Wollmilchsau.Services.Ldap; using Wollmilchsau.Services.Moodle; 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 LdapUsersCacheKey = "admin:ldap-users"; private const string ClassroomsCacheKey = "admin:classrooms"; @@ -74,6 +77,124 @@ public sealed class AdminEffects(IMoodleService moodleService, ILdapService ldap 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 TryDeleteMoodleUserBatch( + IReadOnlyCollection 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] public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher) { @@ -121,6 +242,30 @@ public sealed class AdminEffects(IMoodleService moodleService, ILdapService ldap 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] public async Task HandleGetAllClassrooms(GetAllClassrooms action, IDispatcher dispatcher) { diff --git a/Wollmilchsau/Store/AdminStore/AdminReducers.cs b/Wollmilchsau/Store/AdminStore/AdminReducers.cs index bac44a0..1212072 100644 --- a/Wollmilchsau/Store/AdminStore/AdminReducers.cs +++ b/Wollmilchsau/Store/AdminStore/AdminReducers.cs @@ -12,7 +12,10 @@ public static class AdminReducers { MoodleUsers = action.Users.ToImmutableDictionary(user => user.Id), 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] public static AdminState ReduceSetLdapUsers(AdminState state, SetLdapUsers action) { diff --git a/Wollmilchsau/Store/AdminStore/AdminState.cs b/Wollmilchsau/Store/AdminStore/AdminState.cs index 38ece97..182f926 100644 --- a/Wollmilchsau/Store/AdminStore/AdminState.cs +++ b/Wollmilchsau/Store/AdminStore/AdminState.cs @@ -12,6 +12,9 @@ public sealed record AdminState public ImmutableDictionary MoodleUsers { get; init; } = []; public bool MoodleUsersLoaded { get; init; } public bool MoodleUsersLoadFailed { get; init; } + public bool MoodleUsersDeletionInProgress { get; init; } + public int MoodleUsersDeletedCount { get; init; } + public int MoodleUsersDeletionTotalCount { get; init; } public ImmutableDictionary LdapUsers { get; init; } = []; public ImmutableList Classrooms { get; init; } = []; public bool LdapUsersLoaded { get; init; }