diff --git a/Wollmilchsau/Components/Pages/Admin/AdminHome.razor b/Wollmilchsau/Components/Pages/Admin/AdminHome.razor index ffe6d78..5f95d6c 100644 --- a/Wollmilchsau/Components/Pages/Admin/AdminHome.razor +++ b/Wollmilchsau/Components/Pages/Admin/AdminHome.razor @@ -6,62 +6,95 @@ @inject Fluxor.IState AdminState @inject IJSRuntime JsRuntime -LDAP Users +User Comparison - + -

LDAP Users

+

User Comparison

-@if (!AdminState.Value.LdapUsersLoaded) +@if (!AdminState.Value.LdapUsersLoaded || !AdminState.Value.MoodleUsersLoaded) {

Loading users...

} -else if (!Users.Any()) -{ -

No LDAP users found.

-} else { - - - - - - - - - - - - - @foreach (var user in Users) - { - - - - - - - - - } - -
UsernameSurnameClassroomGiven nameIncompleteRole
@user.Username@user.Surname@user.Classroom@user.GivenName@user.Incomplete@user.Role
+ @if (AdminState.Value.MoodleUsersLoadFailed) + { + + Moodle users could not be loaded. LDAP users are shown without Moodle matches. + + } + + @if (!CombinedUsers.Any()) + { +

No users found.

+ } + else + { + + + Username + Classroom + Surname + Given name + Role + Exists in LDAP + Exists in Moodle + + + @context.Username + @context.Classroom + @context.Surname + @context.GivenName + @context.Role + @(context.ExistsInLdap ? "Yes" : "No") + @(context.ExistsInMoodle ? "Yes" : "No") + + + + + + } } @code { - private IEnumerable Users => AdminState.Value.LdapUsers.Values; + private IEnumerable LdapUsers => AdminState.Value.LdapUsers.Values; + + private IEnumerable CombinedUsers + { + get + { + var ldapUsers = AdminState.Value.LdapUsers; + var moodleUsers = AdminState.Value.MoodleUsers.Values.ToArray(); + var moodleUsersByUsername = moodleUsers + .Where(moodleUser => !string.IsNullOrWhiteSpace(moodleUser.Username)) + .GroupBy(moodleUser => moodleUser.Username!, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + return ldapUsers.Values + .Select(ldapUser => CreateCombinedUser( + ldapUser, + moodleUsersByUsername.GetValueOrDefault(ldapUser.Username))) + .Concat(moodleUsers + .Where(moodleUser => + string.IsNullOrWhiteSpace(moodleUser.Username) || + !ldapUsers.ContainsKey(moodleUser.Username)) + .Select(moodleUser => CreateCombinedUser(null, moodleUser))) + .OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase); + } + } protected override void OnInitialized() { base.OnInitialized(); + Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllMoodleUsers()); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers()); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms()); } private Task DownloadCsvAsync() { - var csv = string.Join("\r\n", Users + var csv = string.Join("\r\n", LdapUsers .Where(user => user.Role == Wollmilchsau.Models.LdapUserRole.Student) .Select(user => string.Join(';', user.GivenName ?? string.Empty, @@ -71,4 +104,27 @@ else return JsRuntime.InvokeVoidAsync("downloadTextFile", "ldap-users.csv", csv).AsTask(); } -} \ No newline at end of file + + private static CombinedUser CreateCombinedUser( + Wollmilchsau.Models.LdapUser? ldapUser, + Wollmilchsau.Models.MoodleUser? moodleUser) + { + return new CombinedUser( + ldapUser?.Username ?? moodleUser?.Username ?? string.Empty, + ldapUser?.Classroom, + ldapUser?.Surname ?? moodleUser?.Lastname, + ldapUser?.GivenName ?? moodleUser?.Firstname, + ldapUser?.Role, + ldapUser is not null, + moodleUser is not null); + } + + private sealed record CombinedUser( + string Username, + string? Classroom, + string? Surname, + string? GivenName, + Wollmilchsau.Models.LdapUserRole? Role, + bool ExistsInLdap, + bool ExistsInMoodle); +} diff --git a/Wollmilchsau/Models/MoodleUser.cs b/Wollmilchsau/Models/MoodleUser.cs index 8c44414..fec10d4 100644 --- a/Wollmilchsau/Models/MoodleUser.cs +++ b/Wollmilchsau/Models/MoodleUser.cs @@ -2,4 +2,25 @@ public sealed record MoodleUser { + public int Id { get; init; } + public string? Username { get; init; } + public string? Firstname { get; init; } + public string? Lastname { get; init; } + public string? Fullname { get; init; } + public string? Initials { get; init; } + public string? Department { get; init; } + public long Firstaccess { get; init; } + public long Lastaccess { get; init; } + public string? Auth { get; init; } + public bool Suspended { get; init; } + public bool Confirmed { get; init; } + public string? Lang { get; init; } + public string? Theme { get; init; } + public string? Timezone { get; init; } + public int Mailformat { get; init; } + public int Trackforums { get; init; } + public string? Description { get; init; } + public int Descriptionformat { get; init; } + public string? Profileimageurlsmall { get; init; } + public string? Profileimageurl { get; init; } } diff --git a/Wollmilchsau/Program.cs b/Wollmilchsau/Program.cs index 00d8a0f..0b58d7d 100644 --- a/Wollmilchsau/Program.cs +++ b/Wollmilchsau/Program.cs @@ -12,6 +12,7 @@ using System.Text.Json; using Wollmilchsau.Components; using Wollmilchsau.Exceptions; using Wollmilchsau.Services.Ldap; +using Wollmilchsau.Services.Moodle; var builder = WebApplication.CreateBuilder(args); @@ -90,6 +91,17 @@ builder.Services.AddOptions() .ValidateOnStart(); builder.Services.AddScoped(); +builder.Services.AddOptions() + .BindConfiguration("Moodle") + .ValidateDataAnnotations() + .ValidateOnStart(); +builder.Services.AddHttpClient((serviceProvider, client) => +{ + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl, UriKind.Absolute); + client.Timeout = TimeSpan.FromSeconds(30); +}); + // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); diff --git a/Wollmilchsau/Services/Moodle/IMoodleService.cs b/Wollmilchsau/Services/Moodle/IMoodleService.cs new file mode 100644 index 0000000..792fb19 --- /dev/null +++ b/Wollmilchsau/Services/Moodle/IMoodleService.cs @@ -0,0 +1,8 @@ +using Wollmilchsau.Models; + +namespace Wollmilchsau.Services.Moodle; + +public interface IMoodleService +{ + Task> GetAllUsers(CancellationToken cancellationToken = default); +} diff --git a/Wollmilchsau/Services/Moodle/MoodleOptions.cs b/Wollmilchsau/Services/Moodle/MoodleOptions.cs new file mode 100644 index 0000000..020a1d9 --- /dev/null +++ b/Wollmilchsau/Services/Moodle/MoodleOptions.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Wollmilchsau.Services.Moodle; + +public sealed class MoodleOptions +{ + [Required] + [Url] + public string BaseUrl { get; init; } = string.Empty; + + [Required] + public string WsToken { get; init; } = string.Empty; +} diff --git a/Wollmilchsau/Services/Moodle/MoodleService.cs b/Wollmilchsau/Services/Moodle/MoodleService.cs new file mode 100644 index 0000000..3af5a12 --- /dev/null +++ b/Wollmilchsau/Services/Moodle/MoodleService.cs @@ -0,0 +1,47 @@ +using System.Net.Http.Json; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using Wollmilchsau.Models; + +namespace Wollmilchsau.Services.Moodle; + +public sealed class MoodleService(HttpClient httpClient, IOptions options) : IMoodleService +{ + private readonly MoodleOptions _options = options.Value; + + public async Task> GetAllUsers(CancellationToken cancellationToken = default) + { + var requestUri = QueryHelpers.AddQueryString( + string.Empty, + new Dictionary + { + ["wstoken"] = _options.WsToken, + ["wsfunction"] = "core_user_get_users", + ["moodlewsrestformat"] = "json", + ["criteria[0][key]"] = "lastname", + ["criteria[0][value]"] = "%" + }); + + using var response = await httpClient.GetAsync(requestUri, cancellationToken); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(cancellationToken); + if (result is null) + { + throw new InvalidOperationException("Moodle returned an empty user search response."); + } + + if (!string.IsNullOrWhiteSpace(result.Exception)) + { + throw new InvalidOperationException($"Moodle user search failed with {result.ErrorCode ?? "an unspecified error"}: {result.Message ?? result.Exception}."); + } + + return result.Users ?? throw new InvalidOperationException("Moodle user search response did not contain users."); + } + + private sealed record MoodleUserSearchResponse( + IReadOnlyCollection? Users, + string? Exception, + string? ErrorCode, + string? Message); +} diff --git a/Wollmilchsau/Store/AdminStore/AdminActions.cs b/Wollmilchsau/Store/AdminStore/AdminActions.cs index f2a709b..2ce5bd9 100644 --- a/Wollmilchsau/Store/AdminStore/AdminActions.cs +++ b/Wollmilchsau/Store/AdminStore/AdminActions.cs @@ -3,6 +3,8 @@ namespace Wollmilchsau.Store.AdminStore; public sealed record GetAllMoodleUsers(); +public sealed record SetMoodleUsers(IReadOnlyCollection Users); +public sealed record SetMoodleUsersLoadFailed(); public sealed record GetAllElementUsers(); public sealed record GetAllLdapUsers(); public sealed record SetLdapUsers(IReadOnlyCollection Users); diff --git a/Wollmilchsau/Store/AdminStore/AdminEffects.cs b/Wollmilchsau/Store/AdminStore/AdminEffects.cs index 2036cf1..d9cadea 100644 --- a/Wollmilchsau/Store/AdminStore/AdminEffects.cs +++ b/Wollmilchsau/Store/AdminStore/AdminEffects.cs @@ -3,14 +3,77 @@ using Fluxor; using Microsoft.Extensions.Caching.Distributed; using Wollmilchsau.Models; using Wollmilchsau.Services.Ldap; +using Wollmilchsau.Services.Moodle; namespace Wollmilchsau.Store.AdminStore; -public sealed class AdminEffects(ILdapService ldapService, IDistributedCache cache) +public sealed class AdminEffects(IMoodleService moodleService, ILdapService ldapService, IDistributedCache cache) { + private const string MoodleUsersCacheKey = "admin:moodle-users"; private const string LdapUsersCacheKey = "admin:ldap-users"; private const string ClassroomsCacheKey = "admin:classrooms"; + [EffectMethod] + public async Task HandleGetAllMoodleUsers(GetAllMoodleUsers action, IDispatcher dispatcher) + { + try + { + var cachedUsers = await cache.GetStringAsync(MoodleUsersCacheKey); + IReadOnlyCollection users; + + if (cachedUsers is not null) + { + var deserializedUsers = JsonSerializer.Deserialize(cachedUsers); + if (deserializedUsers is not null) + { + users = deserializedUsers; + } + else + { + await cache.RemoveAsync(MoodleUsersCacheKey); + users = await LoadAndCacheMoodleUsers(); + } + } + else + { + users = await LoadAndCacheMoodleUsers(); + } + + dispatcher.Dispatch(new SetMoodleUsers(users)); + } + catch (HttpRequestException) + { + dispatcher.Dispatch(new SetMoodleUsersLoadFailed()); + } + catch (JsonException) + { + dispatcher.Dispatch(new SetMoodleUsersLoadFailed()); + } + catch (InvalidOperationException) + { + dispatcher.Dispatch(new SetMoodleUsersLoadFailed()); + } + catch (TaskCanceledException) + { + dispatcher.Dispatch(new SetMoodleUsersLoadFailed()); + } + } + + private async Task> LoadAndCacheMoodleUsers() + { + var users = await moodleService.GetAllUsers(); + + await cache.SetStringAsync( + MoodleUsersCacheKey, + JsonSerializer.Serialize(users), + new DistributedCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromMinutes(10) + }); + + return users; + } + [EffectMethod] public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher) { diff --git a/Wollmilchsau/Store/AdminStore/AdminReducers.cs b/Wollmilchsau/Store/AdminStore/AdminReducers.cs index 19b00c4..bac44a0 100644 --- a/Wollmilchsau/Store/AdminStore/AdminReducers.cs +++ b/Wollmilchsau/Store/AdminStore/AdminReducers.cs @@ -5,6 +5,28 @@ namespace Wollmilchsau.Store.AdminStore; public static class AdminReducers { + [ReducerMethod] + public static AdminState ReduceSetMoodleUsers(AdminState state, SetMoodleUsers action) + { + return state with + { + MoodleUsers = action.Users.ToImmutableDictionary(user => user.Id), + MoodleUsersLoaded = true, + MoodleUsersLoadFailed = false + }; + } + + [ReducerMethod] + public static AdminState ReduceSetMoodleUsersLoadFailed(AdminState state, SetMoodleUsersLoadFailed action) + { + return state with + { + MoodleUsers = [], + MoodleUsersLoaded = true, + MoodleUsersLoadFailed = true + }; + } + [ReducerMethod] public static AdminState ReduceSetLdapUsers(AdminState state, SetLdapUsers action) { diff --git a/Wollmilchsau/Store/AdminStore/AdminState.cs b/Wollmilchsau/Store/AdminStore/AdminState.cs index e613447..38ece97 100644 --- a/Wollmilchsau/Store/AdminStore/AdminState.cs +++ b/Wollmilchsau/Store/AdminStore/AdminState.cs @@ -9,7 +9,9 @@ public sealed record AdminState { public AdminState() { } public ImmutableDictionary ElementUsers { get; init; } = []; - public ImmutableDictionary MoodleUsers { get; init; } = []; + public ImmutableDictionary MoodleUsers { get; init; } = []; + public bool MoodleUsersLoaded { get; init; } + public bool MoodleUsersLoadFailed { get; init; } public ImmutableDictionary LdapUsers { get; init; } = []; public ImmutableList Classrooms { get; init; } = []; public bool LdapUsersLoaded { get; init; }