Added loading of Moodle Users and displaying them

This commit is contained in:
Rhywden
2026-08-24 13:05:14 +02:00
parent edba5d8478
commit a030cc517a
10 changed files with 284 additions and 38 deletions
@@ -6,62 +6,95 @@
@inject Fluxor.IState<Wollmilchsau.Store.AdminStore.AdminState> AdminState @inject Fluxor.IState<Wollmilchsau.Store.AdminStore.AdminState> AdminState
@inject IJSRuntime JsRuntime @inject IJSRuntime JsRuntime
<PageTitle>LDAP Users</PageTitle> <PageTitle>User Comparison</PageTitle>
<button class="btn btn-primary" @onclick="DownloadCsvAsync" disabled="@(!Users.Any())">Export CSV</button> <button class="btn btn-primary" @onclick="DownloadCsvAsync" disabled="@(!LdapUsers.Any())">Export CSV</button>
<h1>LDAP Users</h1> <h1>User Comparison</h1>
@if (!AdminState.Value.LdapUsersLoaded) @if (!AdminState.Value.LdapUsersLoaded || !AdminState.Value.MoodleUsersLoaded)
{ {
<p><em>Loading users...</em></p> <p><em>Loading users...</em></p>
} }
else if (!Users.Any())
{
<p>No LDAP users found.</p>
}
else else
{ {
<table class="table"> @if (AdminState.Value.MoodleUsersLoadFailed)
<thead> {
<tr> <MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
<th>Username</th> Moodle users could not be loaded. LDAP users are shown without Moodle matches.
<th>Surname</th> </MudAlert>
<th>Classroom</th> }
<th>Given name</th>
<th>Incomplete</th> @if (!CombinedUsers.Any())
<th>Role</th> {
</tr> <p>No users found.</p>
</thead> }
<tbody> else
@foreach (var user in Users) {
{ <MudTable T="CombinedUser" Items="@CombinedUsers" Hover="true" Dense="true" RowsPerPage="25">
<tr> <HeaderContent>
<td>@user.Username</td> <MudTh>Username</MudTh>
<td>@user.Surname</td> <MudTh>Classroom</MudTh>
<td>@user.Classroom</td> <MudTh>Surname</MudTh>
<td>@user.GivenName</td> <MudTh>Given name</MudTh>
<td>@user.Incomplete</td> <MudTh>Role</MudTh>
<td>@user.Role</td> <MudTh>Exists in LDAP</MudTh>
</tr> <MudTh>Exists in Moodle</MudTh>
} </HeaderContent>
</tbody> <RowTemplate>
</table> <MudTd DataLabel="Username">@context.Username</MudTd>
<MudTd DataLabel="Classroom">@context.Classroom</MudTd>
<MudTd DataLabel="Surname">@context.Surname</MudTd>
<MudTd DataLabel="Given name">@context.GivenName</MudTd>
<MudTd DataLabel="Role">@context.Role</MudTd>
<MudTd DataLabel="Exists in LDAP">@(context.ExistsInLdap ? "Yes" : "No")</MudTd>
<MudTd DataLabel="Exists in Moodle">@(context.ExistsInMoodle ? "Yes" : "No")</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
}
} }
@code { @code {
private IEnumerable<Wollmilchsau.Models.LdapUser> Users => AdminState.Value.LdapUsers.Values; private IEnumerable<Wollmilchsau.Models.LdapUser> LdapUsers => AdminState.Value.LdapUsers.Values;
private IEnumerable<CombinedUser> 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() protected override void OnInitialized()
{ {
base.OnInitialized(); base.OnInitialized();
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllMoodleUsers());
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers()); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers());
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms()); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms());
} }
private Task DownloadCsvAsync() 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) .Where(user => user.Role == Wollmilchsau.Models.LdapUserRole.Student)
.Select(user => string.Join(';', .Select(user => string.Join(';',
user.GivenName ?? string.Empty, user.GivenName ?? string.Empty,
@@ -71,4 +104,27 @@ else
return JsRuntime.InvokeVoidAsync("downloadTextFile", "ldap-users.csv", csv).AsTask(); return JsRuntime.InvokeVoidAsync("downloadTextFile", "ldap-users.csv", csv).AsTask();
} }
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);
} }
+21
View File
@@ -2,4 +2,25 @@
public sealed record MoodleUser 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; }
} }
+12
View File
@@ -12,6 +12,7 @@ using System.Text.Json;
using Wollmilchsau.Components; using Wollmilchsau.Components;
using Wollmilchsau.Exceptions; using Wollmilchsau.Exceptions;
using Wollmilchsau.Services.Ldap; using Wollmilchsau.Services.Ldap;
using Wollmilchsau.Services.Moodle;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -90,6 +91,17 @@ builder.Services.AddOptions<LdapOptions>()
.ValidateOnStart(); .ValidateOnStart();
builder.Services.AddScoped<ILdapService, LdapService>(); builder.Services.AddScoped<ILdapService, LdapService>();
builder.Services.AddOptions<MoodleOptions>()
.BindConfiguration("Moodle")
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddHttpClient<IMoodleService, MoodleService>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<MoodleOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl, UriKind.Absolute);
client.Timeout = TimeSpan.FromSeconds(30);
});
// Add services to the container. // Add services to the container.
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
@@ -0,0 +1,8 @@
using Wollmilchsau.Models;
namespace Wollmilchsau.Services.Moodle;
public interface IMoodleService
{
Task<IReadOnlyCollection<MoodleUser>> GetAllUsers(CancellationToken cancellationToken = default);
}
@@ -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;
}
@@ -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<MoodleOptions> options) : IMoodleService
{
private readonly MoodleOptions _options = options.Value;
public async Task<IReadOnlyCollection<MoodleUser>> GetAllUsers(CancellationToken cancellationToken = default)
{
var requestUri = QueryHelpers.AddQueryString(
string.Empty,
new Dictionary<string, string?>
{
["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<MoodleUserSearchResponse>(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<MoodleUser>? Users,
string? Exception,
string? ErrorCode,
string? Message);
}
@@ -3,6 +3,8 @@
namespace Wollmilchsau.Store.AdminStore; namespace Wollmilchsau.Store.AdminStore;
public sealed record GetAllMoodleUsers(); public sealed record GetAllMoodleUsers();
public sealed record SetMoodleUsers(IReadOnlyCollection<MoodleUser> Users);
public sealed record SetMoodleUsersLoadFailed();
public sealed record GetAllElementUsers(); public sealed record GetAllElementUsers();
public sealed record GetAllLdapUsers(); public sealed record GetAllLdapUsers();
public sealed record SetLdapUsers(IReadOnlyCollection<LdapUser> Users); public sealed record SetLdapUsers(IReadOnlyCollection<LdapUser> Users);
+64 -1
View File
@@ -3,14 +3,77 @@ using Fluxor;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Wollmilchsau.Models; using Wollmilchsau.Models;
using Wollmilchsau.Services.Ldap; using Wollmilchsau.Services.Ldap;
using Wollmilchsau.Services.Moodle;
namespace Wollmilchsau.Store.AdminStore; 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 LdapUsersCacheKey = "admin:ldap-users";
private const string ClassroomsCacheKey = "admin:classrooms"; private const string ClassroomsCacheKey = "admin:classrooms";
[EffectMethod]
public async Task HandleGetAllMoodleUsers(GetAllMoodleUsers action, IDispatcher dispatcher)
{
try
{
var cachedUsers = await cache.GetStringAsync(MoodleUsersCacheKey);
IReadOnlyCollection<MoodleUser> users;
if (cachedUsers is not null)
{
var deserializedUsers = JsonSerializer.Deserialize<MoodleUser[]>(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<IReadOnlyCollection<MoodleUser>> LoadAndCacheMoodleUsers()
{
var users = await moodleService.GetAllUsers();
await cache.SetStringAsync(
MoodleUsersCacheKey,
JsonSerializer.Serialize(users),
new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(10)
});
return users;
}
[EffectMethod] [EffectMethod]
public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher) public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher)
{ {
@@ -5,6 +5,28 @@ namespace Wollmilchsau.Store.AdminStore;
public static class AdminReducers 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] [ReducerMethod]
public static AdminState ReduceSetLdapUsers(AdminState state, SetLdapUsers action) public static AdminState ReduceSetLdapUsers(AdminState state, SetLdapUsers action)
{ {
+3 -1
View File
@@ -9,7 +9,9 @@ public sealed record AdminState
{ {
public AdminState() { } public AdminState() { }
public ImmutableDictionary<Guid, ElementUser> ElementUsers { get; init; } = []; public ImmutableDictionary<Guid, ElementUser> ElementUsers { get; init; } = [];
public ImmutableDictionary<Guid, MoodleUser> MoodleUsers { get; init; } = []; public ImmutableDictionary<int, MoodleUser> MoodleUsers { get; init; } = [];
public bool MoodleUsersLoaded { get; init; }
public bool MoodleUsersLoadFailed { 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; }