Also now loading the classrooms in adminstate

This commit is contained in:
Rhywden
2026-08-23 16:02:06 +02:00
parent 3713bca3dc
commit 8f006ec568
9 changed files with 185 additions and 17 deletions
+1
View File
@@ -54,6 +54,7 @@ else
{
base.OnInitialized();
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers());
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms());
}
private Task DownloadCsvAsync()
+3
View File
@@ -0,0 +1,3 @@
namespace Wollmilchsau.Models;
public sealed record Classroom(string Name);
@@ -4,5 +4,8 @@ namespace Wollmilchsau.Services.Ldap;
public interface ILdapService
{
Task<int> GetUserCount(CancellationToken cancellationToken = default);
IAsyncEnumerable<LdapUser> GetAllUsers(CancellationToken cancellationToken = default);
Task<int> GetClassroomCount(CancellationToken cancellationToken = default);
IAsyncEnumerable<Classroom> GetAllClassrooms(CancellationToken cancellationToken = default);
}
@@ -27,4 +27,10 @@ public sealed class LdapOptions
[Required]
public string TeacherRole { get; init; } = string.Empty;
[Required]
public string GroupBase { get; init; } = string.Empty;
[Required]
public string KlasseGroupFilter { get; init; } = string.Empty;
}
+79 -1
View File
@@ -10,6 +10,21 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
{
private readonly LdapOptions _options = options.Value;
public Task<int> GetUserCount(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
using var connection = CreateConnection();
var request = new SearchRequest(
BuildDistinguishedName(_options.UserBase),
GetSupportedRolesFilter(),
SearchScope.Subtree,
"1.1");
var response = (SearchResponse)connection.SendRequest(request);
return Task.FromResult(response.Entries.Count);
}
public async IAsyncEnumerable<LdapUser> GetAllUsers(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
@@ -24,6 +39,45 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
}
}
public Task<int> GetClassroomCount(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
using var connection = CreateConnection();
var request = new SearchRequest(
BuildDistinguishedName(_options.GroupBase),
_options.KlasseGroupFilter,
SearchScope.Subtree,
"1.1");
var response = (SearchResponse)connection.SendRequest(request);
return Task.FromResult(response.Entries.Count);
}
public async IAsyncEnumerable<Classroom> GetAllClassrooms(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
using var connection = CreateConnection();
var request = new SearchRequest(
BuildDistinguishedName(_options.GroupBase),
_options.KlasseGroupFilter,
SearchScope.Subtree,
"cn");
var response = (SearchResponse)connection.SendRequest(request);
foreach (var entry in response.Entries.Cast<SearchResultEntry>())
{
cancellationToken.ThrowIfCancellationRequested();
var commonName = GetFirstAttributeValue(entry, "cn")
?? throw new InvalidOperationException($"LDAP classroom entry '{entry.DistinguishedName}' is missing cn.");
yield return new Classroom(GetClassroomName(commonName));
await Task.Yield();
}
}
private LdapConnection CreateConnection()
{
var ldapUri = new Uri(_options.LdapUri, UriKind.Absolute);
@@ -52,7 +106,7 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
var teacherRoleDn = BuildDistinguishedName(_options.TeacherRole, _options.RoleBase);
var request = new SearchRequest(
BuildDistinguishedName(_options.UserBase),
$"(|(memberOf={EscapeFilterValue(studentRoleDn)})(memberOf={EscapeFilterValue(teacherRoleDn)}))",
GetSupportedRolesFilter(studentRoleDn, teacherRoleDn),
SearchScope.Subtree,
"uid",
"sn",
@@ -102,6 +156,30 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
throw new InvalidOperationException($"LDAP user entry '{entry.DistinguishedName}' is not a member of a supported role.");
}
private string GetSupportedRolesFilter()
{
var studentRoleDn = BuildDistinguishedName(_options.StudentsRole, _options.RoleBase);
var teacherRoleDn = BuildDistinguishedName(_options.TeacherRole, _options.RoleBase);
return GetSupportedRolesFilter(studentRoleDn, teacherRoleDn);
}
private static string GetSupportedRolesFilter(string studentRoleDn, string teacherRoleDn)
{
return $"(|(memberOf={EscapeFilterValue(studentRoleDn)})(memberOf={EscapeFilterValue(teacherRoleDn)}))";
}
private static string GetClassroomName(string commonName)
{
const string classroomPrefix = "klasse.";
if (!commonName.StartsWith(classroomPrefix, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"LDAP classroom cn '{commonName}' does not start with '{classroomPrefix}'.");
}
return commonName[classroomPrefix.Length..];
}
private string BuildDistinguishedName(params string[] relativeNames)
{
return string.Join(',', relativeNames.Append(_options.BaseDN));
@@ -6,4 +6,6 @@ public sealed record GetAllMoodleUsers();
public sealed record GetAllElementUsers();
public sealed record GetAllLdapUsers();
public sealed record SetLdapUsers(IReadOnlyCollection<LdapUser> Users);
public sealed record GetAllClassrooms();
public sealed record SetClassrooms(IReadOnlyCollection<Classroom> Classrooms);
public sealed record GetAllMsolUsers();
+69 -6
View File
@@ -9,36 +9,99 @@ namespace Wollmilchsau.Store.AdminStore;
public sealed class AdminEffects(ILdapService ldapService, IDistributedCache cache)
{
private const string LdapUsersCacheKey = "admin:ldap-users";
private const string ClassroomsCacheKey = "admin:classrooms";
[EffectMethod]
public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher)
{
var ldapUserCount = await ldapService.GetUserCount();
var cachedUsers = await cache.GetStringAsync(LdapUsersCacheKey);
IReadOnlyCollection<LdapUser> users;
if (cachedUsers is not null)
{
users = JsonSerializer.Deserialize<LdapUser[]>(cachedUsers)
?? throw new InvalidOperationException("The cached LDAP users could not be deserialized.");
var deserializedUsers = JsonSerializer.Deserialize<LdapUser[]>(cachedUsers);
if (deserializedUsers is not null && deserializedUsers.Length == ldapUserCount)
{
users = deserializedUsers;
}
else
{
var loadedUsers = new List<LdapUser>();
await cache.RemoveAsync(LdapUsersCacheKey);
users = await LoadAndCacheLdapUsers(ldapUserCount);
}
}
else
{
users = await LoadAndCacheLdapUsers(ldapUserCount);
}
dispatcher.Dispatch(new SetLdapUsers(users));
}
private async Task<IReadOnlyCollection<LdapUser>> LoadAndCacheLdapUsers(int ldapUserCount)
{
var loadedUsers = new List<LdapUser>(ldapUserCount);
await foreach (var user in ldapService.GetAllUsers())
{
loadedUsers.Add(user);
}
users = loadedUsers;
await cache.SetStringAsync(
LdapUsersCacheKey,
JsonSerializer.Serialize(users),
JsonSerializer.Serialize(loadedUsers),
new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(10)
});
return loadedUsers;
}
dispatcher.Dispatch(new SetLdapUsers(users));
[EffectMethod]
public async Task HandleGetAllClassrooms(GetAllClassrooms action, IDispatcher dispatcher)
{
var ldapClassroomCount = await ldapService.GetClassroomCount();
var cachedClassrooms = await cache.GetStringAsync(ClassroomsCacheKey);
IReadOnlyCollection<Classroom> classrooms;
if (cachedClassrooms is not null)
{
var deserializedClassrooms = JsonSerializer.Deserialize<Classroom[]>(cachedClassrooms);
if (deserializedClassrooms is not null && deserializedClassrooms.Length == ldapClassroomCount)
{
classrooms = deserializedClassrooms;
}
else
{
await cache.RemoveAsync(ClassroomsCacheKey);
classrooms = await LoadAndCacheClassrooms(ldapClassroomCount);
}
}
else
{
classrooms = await LoadAndCacheClassrooms(ldapClassroomCount);
}
dispatcher.Dispatch(new SetClassrooms(classrooms));
}
private async Task<IReadOnlyCollection<Classroom>> LoadAndCacheClassrooms(int ldapClassroomCount)
{
var loadedClassrooms = new List<Classroom>(ldapClassroomCount);
await foreach (var classroom in ldapService.GetAllClassrooms())
{
loadedClassrooms.Add(classroom);
}
await cache.SetStringAsync(
ClassroomsCacheKey,
JsonSerializer.Serialize(loadedClassrooms),
new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(10)
});
return loadedClassrooms;
}
}
@@ -14,4 +14,15 @@ public static class AdminReducers
LdapUsersLoaded = true
};
}
[ReducerMethod]
public static AdminState ReduceSetClassrooms(AdminState state, SetClassrooms action)
{
return state with
{
Classrooms = action.Classrooms
.OrderBy(classroom => classroom.Name, StringComparer.OrdinalIgnoreCase)
.ToImmutableList()
};
}
}
@@ -11,6 +11,7 @@ public sealed record AdminState
public ImmutableDictionary<Guid, ElementUser> ElementUsers { get; init; } = [];
public ImmutableDictionary<Guid, MoodleUser> MoodleUsers { get; init; } = [];
public ImmutableDictionary<string, LdapUser> LdapUsers { get; init; } = [];
public ImmutableList<Classroom> Classrooms { get; init; } = [];
public bool LdapUsersLoaded { get; init; }
public ImmutableDictionary<Guid, MsolUser> MsolUsers { get; init; } = [];
}