Added loading of Moodle Users and displaying them
This commit is contained in:
@@ -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>
|
|
||||||
<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>
|
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
|
||||||
<td>@user.Username</td>
|
Moodle users could not be loaded. LDAP users are shown without Moodle matches.
|
||||||
<td>@user.Surname</td>
|
</MudAlert>
|
||||||
<td>@user.Classroom</td>
|
}
|
||||||
<td>@user.GivenName</td>
|
|
||||||
<td>@user.Incomplete</td>
|
@if (!CombinedUsers.Any())
|
||||||
<td>@user.Role</td>
|
{
|
||||||
</tr>
|
<p>No users found.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTable T="CombinedUser" Items="@CombinedUsers" Hover="true" Dense="true" RowsPerPage="25">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Username</MudTh>
|
||||||
|
<MudTh>Classroom</MudTh>
|
||||||
|
<MudTh>Surname</MudTh>
|
||||||
|
<MudTh>Given name</MudTh>
|
||||||
|
<MudTh>Role</MudTh>
|
||||||
|
<MudTh>Exists in LDAP</MudTh>
|
||||||
|
<MudTh>Exists in Moodle</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<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>
|
||||||
}
|
}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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);
|
||||||
}
|
}
|
||||||
@@ -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,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);
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user