From c7f895a9bf9f870beafc0e285162a9f1589c54f8 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 17 Feb 2025 09:40:04 -0500 Subject: [PATCH 1/7] Logging config cleanup and resolves #562 --- .../WebApplicationBuilderExtensions.cs | 92 +++++++++++-------- .../Features/Game/GameService.cs | 19 ++-- 2 files changed, 59 insertions(+), 52 deletions(-) diff --git a/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs b/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs index 3f8cf1cb..ff5e1237 100644 --- a/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs @@ -68,46 +68,6 @@ public static void ConfigureServices(this WebApplicationBuilder builder, AppSett { var services = builder.Services; - // serilog config - builder.Host.UseSerilog(); - - Serilog.Debugging.SelfLog.Enable(Console.Error); - var loggerConfiguration = new LoggerConfiguration() - .MinimumLevel.Is(settings.Logging.MinimumLogLevel) - .WriteTo.Console(theme: AnsiConsoleTheme.Code); - - // normally, you'd do this in an appsettings.json and just rely on built-in config - // assembly stuff, but we distribute with a helm chart and a weird conf format, so - // we need to manually set up the log levels - foreach (var logNamespace in settings.Logging.NamespacesErrorLevel) - { - loggerConfiguration = loggerConfiguration.MinimumLevel.Override(logNamespace, LogEventLevel.Error); - } - - foreach (var logNamespace in settings.Logging.NamespacesFatalLevel) - { - loggerConfiguration = loggerConfiguration.MinimumLevel.Override(logNamespace, LogEventLevel.Fatal); - } - - foreach (var logNamespace in settings.Logging.NamespacesInfoLevel) - { - loggerConfiguration = loggerConfiguration.MinimumLevel.Override(logNamespace, LogEventLevel.Information); - } - - foreach (var logNamespace in settings.Logging.NamespacesWarningLevel) - { - loggerConfiguration = loggerConfiguration.MinimumLevel.Override(logNamespace, LogEventLevel.Warning); - } - - // set up sinks on demand - if (settings.Logging.SeqInstanceUrl.IsNotEmpty()) - { - loggerConfiguration = loggerConfiguration.WriteTo.Seq(settings.Logging.SeqInstanceUrl, apiKey: settings.Logging.SeqInstanceApiKey); - } - - // weirdly, this really does appear to be the way to replace the default logger with Serilog 🤷 - Log.Logger = loggerConfiguration.CreateLogger(); - services .AddMvc() .AddGameboardJsonOptions(); @@ -124,6 +84,8 @@ public static void ConfigureServices(this WebApplicationBuilder builder, AppSett .SetApplicationName(AppConstants.DataProtectionPurpose) .PersistKeys(() => settings.Cache); + builder.AddGameboardSerilog(settings); + services .AddSingleton(_ => settings.Core) .AddSingleton(_ => settings.Crucible) @@ -149,4 +111,54 @@ public static void ConfigureServices(this WebApplicationBuilder builder, AppSett services.AddConfiguredAuthentication(settings.Oidc, settings.ApiKey, builder.Environment); services.AddConfiguredAuthorization(); } + + private static WebApplicationBuilder AddGameboardSerilog(this WebApplicationBuilder builder, AppSettings settings) + { + // SERILOG CONFIG + // Gameboard uses Serilog, which is awesome, because Serilog is awesome. By default, it just + // writes to the console sink, which you can ingest with any log ingester (and is useful if you + // choose to monitor the output of its pod in a K8s-style scenario). But if you want richer logging, + // you can add a Seq instance using its configuration so you get nice metadata like the userID + // and name for API requests. Want to use a non-Seq sink? We get it. PR us and let's talk about it. + builder.Host.UseSerilog(); + + Serilog.Debugging.SelfLog.Enable(Console.Error); + var loggerConfiguration = new LoggerConfiguration() + .MinimumLevel.Is(settings.Logging.MinimumLogLevel) + .WriteTo.Console(theme: AnsiConsoleTheme.Code); + + // normally, you'd do this in an appsettings.json and just rely on built-in config + // assembly stuff, but we distribute with a helm chart and a weird conf format, so + // we need to manually set up the log levels + foreach (var logNamespace in settings.Logging.NamespacesErrorLevel) + { + loggerConfiguration = loggerConfiguration.MinimumLevel.Override(logNamespace, LogEventLevel.Error); + } + + foreach (var logNamespace in settings.Logging.NamespacesFatalLevel) + { + loggerConfiguration = loggerConfiguration.MinimumLevel.Override(logNamespace, LogEventLevel.Fatal); + } + + foreach (var logNamespace in settings.Logging.NamespacesInfoLevel) + { + loggerConfiguration = loggerConfiguration.MinimumLevel.Override(logNamespace, LogEventLevel.Information); + } + + foreach (var logNamespace in settings.Logging.NamespacesWarningLevel) + { + loggerConfiguration = loggerConfiguration.MinimumLevel.Override(logNamespace, LogEventLevel.Warning); + } + + // set up sinks on demand + if (settings.Logging.SeqInstanceUrl.IsNotEmpty()) + { + loggerConfiguration = loggerConfiguration.WriteTo.Seq(settings.Logging.SeqInstanceUrl, apiKey: settings.Logging.SeqInstanceApiKey); + } + + // weirdly, this really does appear to be the way to replace the default logger with Serilog 🤷 + Log.Logger = loggerConfiguration.CreateLogger(); + + return builder; + } } diff --git a/src/Gameboard.Api/Features/Game/GameService.cs b/src/Gameboard.Api/Features/Game/GameService.cs index bb82e490..20ca8f5d 100644 --- a/src/Gameboard.Api/Features/Game/GameService.cs +++ b/src/Gameboard.Api/Features/Game/GameService.cs @@ -29,7 +29,6 @@ public interface IGameService Task SessionForecast(string id); Task Update(ChangedGame account); Task UpdateImage(string id, string type, string filename); - Task UserIsTeamPlayer(string uid, string tid); } public class GameService @@ -38,11 +37,13 @@ public class GameService IMapper mapper, CoreOptions options, Defaults defaults, + IGuidService guids, INowService nowService, IStore store ) : _Service(logger, mapper, options), IGameService { private readonly Defaults _defaults = defaults; + private readonly IGuidService _guids = guids; private readonly INowService _now = nowService; private readonly IStore _store = store; @@ -217,16 +218,6 @@ public async Task UpdateImage(string id, string type, string filename) public Task IsUserPlaying(string gameId, string userId) => _store.AnyAsync(p => p.GameId == gameId && p.UserId == userId, CancellationToken.None); - public async Task UserIsTeamPlayer(string uid, string tid) - { - bool authd = await _store.AnyAsync(u => - u.Id == uid && - u.Enrollments.Any(e => e.TeamId == tid) - , CancellationToken.None); - - return authd; - } - public async Task DeleteGameCardImage(string gameId) { if (!await _store.WithNoTracking().AnyAsync(g => g.Id == gameId)) @@ -248,6 +239,10 @@ public async Task SaveGameCardImage(string gameId, IFormFile file) if (!await _store.WithNoTracking().AnyAsync(g => g.Id == gameId)) throw new ResourceNotFound(gameId); + // we currently intentionally leave the old image around for quasi-logging purposes, + // but we could delete if desired here by getting the path from the Game entity. + // We generate a semi-random name for the new file in GetGameCardFileNameBase to bypass + // network-level caching. var fileName = $"{GetGameCardFileNameBase(gameId)}{Path.GetExtension(file.FileName.ToLower())}"; var path = Path.Combine(Options.ImageFolder, fileName); @@ -259,7 +254,7 @@ public async Task SaveGameCardImage(string gameId, IFormFile file) } private string GetGameCardFileNameBase(string gameId) - => $"{gameId.ToLower()}_card"; + => $"{gameId.ToLower()}_card_{_guids.Generate()[..6]}"; private IQueryable BuildSearchQuery(GameSearchFilter model, bool canViewUnpublished = false) { From 9280a146b8d7fbb3e1f62472a8b37d62c618db1f Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 17 Feb 2025 14:26:08 -0500 Subject: [PATCH 2/7] Fix cumulative time sort on enrollment report --- .../ImportExport/GameImportExportService.cs | 28 ++++++++----------- .../EnrollmentReportSummary.cs | 2 +- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs index 2a63c072..1566420a 100644 --- a/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs +++ b/src/Gameboard.Api/Features/Game/ImportExport/GameImportExportService.cs @@ -52,9 +52,10 @@ await _store .Where(b => b.Id == exportBatchId) .ExecuteDeleteAsync(cancellationToken); - if (File.Exists(GetExportBatchPackagePath(exportBatchId))) + var packagePath = GetExportBatchPackagePath(exportBatchId); + if (File.Exists(packagePath)) { - File.Delete(GetExportBatchPackagePath(exportBatchId)); + File.Delete(packagePath); } } @@ -396,18 +397,16 @@ public async Task ImportPackage(byte[] package, CancellationToke using (var tempArchiveStream = File.OpenRead(tempArchivePath)) { - using (var reader = ReaderFactory.Open(tempArchiveStream)) + using var reader = ReaderFactory.Open(tempArchiveStream); + while (reader.MoveToNextEntry()) { - while (reader.MoveToNextEntry()) + if (!reader.Entry.IsDirectory) { - if (!reader.Entry.IsDirectory) + reader.WriteEntryToDirectory(GetImportBatchRoot(importBatchId), new ExtractionOptions() { - reader.WriteEntryToDirectory(GetImportBatchRoot(importBatchId), new ExtractionOptions() - { - ExtractFullPath = true, - Overwrite = true - }); - } + ExtractFullPath = true, + Overwrite = true + }); } } } @@ -585,12 +584,7 @@ await _store.SaveAddRange await _store.SaveAddRange(importedGames); - return importedGames.Select(g => new ImportedGame - { - Id = g.Id, - Name = g.Name - }) - .ToArray(); + return [.. importedGames.Select(g => new ImportedGame { Id = g.Id, Name = g.Name })]; } private string GetExportBatchPackageName(string exportBatchId) diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportSummary.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportSummary.cs index 782ccf95..81d42113 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportSummary.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportSummary.cs @@ -65,7 +65,7 @@ public async Task> Handle(EnrollmentReport records = records.Sort(r => r.Game.Name, sortDirection); break; case "time": - records = records.Sort(r => r.PlayTime, sortDirection); + records = records.Sort(r => r.PlayTime.DurationMs, sortDirection); break; } From 4f5473c31b78c3651c650a49c9c78f66c3b2ffe9 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 18 Feb 2025 12:08:03 -0500 Subject: [PATCH 3/7] Resolve #415 --- .../Challenges/ChallengeSyncServiceTests.cs | 5 ++++ .../Features/Challenge/ChallengeController.cs | 4 +++ .../Requests/SyncChallenge/SyncChallenge.cs | 27 +++++++++++++++++++ .../Services/ChallengeSyncService.cs | 20 ++++++++++++++ .../EnrollmentReportService.cs | 7 +++-- 5 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 src/Gameboard.Api/Features/Challenge/Requests/SyncChallenge/SyncChallenge.cs diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeSyncServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeSyncServiceTests.cs index f77f83b3..e9d066fa 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeSyncServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeSyncServiceTests.cs @@ -27,6 +27,7 @@ public async Task GetExpiredChallengesForSync_WithPlayerWithNullishEndDate_DoesN var store = BuildTestableStore(challenge); var sut = new ChallengeSyncService ( + A.Fake(), A.Fake(), A.Fake(), A.Fake>(), @@ -59,6 +60,7 @@ public async Task GetExpiredChallengesForSync_WithPlayerSessionEndInFuture_DoesN var store = BuildTestableStore(challenge); var sut = new ChallengeSyncService ( + A.Fake(), A.Fake(), A.Fake(), A.Fake>(), @@ -91,6 +93,7 @@ public async Task GetExpiredChallengesForSync_WithChallengeAlreadySynced_DoesNot var store = BuildTestableStore(challenge); var sut = new ChallengeSyncService ( + A.Fake(), A.Fake(), A.Fake(), A.Fake>(), @@ -123,6 +126,7 @@ public async Task GetExpiredChallengesForSync_WithNonNullishEndDate_DoesNotSync( var store = BuildTestableStore(challenge); var sut = new ChallengeSyncService ( + A.Fake(), A.Fake(), A.Fake(), A.Fake>(), @@ -155,6 +159,7 @@ public async Task GetExpiredChallengesForSync_WithAllRequiredCriteriaAndNullishE var store = BuildTestableStore(challenge); var sut = new ChallengeSyncService ( + A.Fake(), A.Fake(), A.Fake(), A.Fake>(), diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeController.cs b/src/Gameboard.Api/Features/Challenge/ChallengeController.cs index 4818d2a2..0fc43594 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeController.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeController.cs @@ -194,6 +194,10 @@ await Hub.Clients.Group(result.TeamId).ChallengeEvent return result; } + [HttpPut("/api/challenge/{challengeId}/sync")] + public Task Sync([FromRoute] string challengeId, CancellationToken cancellationToken) + => _mediator.Send(new SyncChallengeCommand(challengeId), cancellationToken); + /// /// Grade a challenge /// diff --git a/src/Gameboard.Api/Features/Challenge/Requests/SyncChallenge/SyncChallenge.cs b/src/Gameboard.Api/Features/Challenge/Requests/SyncChallenge/SyncChallenge.cs new file mode 100644 index 00000000..e2ac4dbb --- /dev/null +++ b/src/Gameboard.Api/Features/Challenge/Requests/SyncChallenge/SyncChallenge.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Features.GameEngine; +using Gameboard.Api.Features.Users; +using Gameboard.Api.Structure.MediatR; +using MediatR; + +namespace Gameboard.Api.Features.Challenges; + +public record SyncChallengeCommand(string ChallengeId) : IRequest; + +internal sealed class SyncChallengeHandler +( + IChallengeSyncService challengeSyncService, + IValidatorService validator +) : IRequestHandler +{ + public async Task Handle(SyncChallengeCommand request, CancellationToken cancellationToken) + { + await validator + .Auth(c => c.Require(PermissionKey.Admin_View)) + .AddEntityExistsValidator(request.ChallengeId) + .Validate(cancellationToken); + + return await challengeSyncService.Sync(request.ChallengeId, cancellationToken); + } +} diff --git a/src/Gameboard.Api/Features/Challenge/Services/ChallengeSyncService.cs b/src/Gameboard.Api/Features/Challenge/Services/ChallengeSyncService.cs index 35700a7b..2208ba4f 100644 --- a/src/Gameboard.Api/Features/Challenge/Services/ChallengeSyncService.cs +++ b/src/Gameboard.Api/Features/Challenge/Services/ChallengeSyncService.cs @@ -16,6 +16,7 @@ namespace Gameboard.Api.Features.Challenges; public interface IChallengeSyncService { + Task Sync(string challengeId, CancellationToken cancellationToken); Task Sync(Data.Challenge challenge, GameEngineGameState challengeState, string actingUserId, CancellationToken cancellationToken); Task SyncExpired(CancellationToken cancellationToken); } @@ -25,6 +26,7 @@ public interface IChallengeSyncService /// internal class ChallengeSyncService ( + IActingUserService actingUser, ConsoleActorMap consoleActorMap, IGameEngineService gameEngine, ILogger logger, @@ -33,6 +35,7 @@ internal class ChallengeSyncService IStore store ) : IChallengeSyncService { + private readonly IActingUserService _actingUser = actingUser; private readonly ConsoleActorMap _consoleActorMap = consoleActorMap; private readonly IGameEngineService _gameEngine = gameEngine; private readonly ILogger _logger = logger; @@ -40,6 +43,23 @@ IStore store private readonly INowService _now = now; private readonly IStore _store = store; + public async Task Sync(string challengeId, CancellationToken cancellationToken) + { + var challenge = await _store + .WithNoTracking() + .SingleOrDefaultAsync(c => c.Id == challengeId, cancellationToken); + + if (challenge is null) + { + return null; + } + + var state = await _gameEngine.GetChallengeState(GameEngineType.TopoMojo, challenge.State); + await Sync(challenge, state, _actingUser.Get()?.Id, cancellationToken); + + return await _gameEngine.GetChallengeState(GameEngineType.TopoMojo, challenge.State); + } + public Task Sync(Data.Challenge challenge, GameEngineGameState state, string actingUserId, CancellationToken cancellationToken) => Sync(cancellationToken, new SyncEntry(actingUserId, challenge, state)); diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs index fa0a3448..58951c2f 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs @@ -16,8 +16,7 @@ public interface IEnrollmentReportService Task GetSummaryStats(EnrollmentReportParameters parameters, CancellationToken cancellationToken); } -internal class EnrollmentReportService( - IReportsService reportsService, +internal class EnrollmentReportService(IReportsService reportsService, IStore store ) : IEnrollmentReportService { @@ -32,8 +31,8 @@ IStore store var seriesCriteria = _reportsService.ParseMultiSelectCriteria(parameters.Series); var sponsorCriteria = _reportsService.ParseMultiSelectCriteria(parameters.Sponsors); var trackCriteria = _reportsService.ParseMultiSelectCriteria(parameters.Tracks); - DateTimeOffset? enrollDateStart = parameters.EnrollDateStart.HasValue ? parameters.EnrollDateStart.Value.ToEndDate().ToUniversalTime() : null; - DateTimeOffset? enrollDateEnd = parameters.EnrollDateEnd.HasValue ? parameters.EnrollDateEnd.Value.ToEndDate().ToUniversalTime() : null; + var enrollDateStart = parameters.EnrollDateStart.HasValue ? parameters.EnrollDateStart.Value.ToEndDate().ToUniversalTime() : default(DateTimeOffset?); + var enrollDateEnd = parameters.EnrollDateEnd.HasValue ? parameters.EnrollDateEnd.Value.ToEndDate().ToUniversalTime() : default(DateTimeOffset?); // the fundamental unit of reporting here is really the player record (an "enrollment"), so resolve enrollments that // meet the filter criteria (and have at least one challenge completed in competitive mode) From 35b28b69ed9ad6c6ea49dfef8dd26bcc35b1ec4c Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 18 Feb 2025 15:42:08 -0500 Subject: [PATCH 4/7] Cleanup --- src/Gameboard.Api/Features/Teams/Services/TeamService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Gameboard.Api/Features/Teams/Services/TeamService.cs b/src/Gameboard.Api/Features/Teams/Services/TeamService.cs index 2af0f37a..f8804439 100644 --- a/src/Gameboard.Api/Features/Teams/Services/TeamService.cs +++ b/src/Gameboard.Api/Features/Teams/Services/TeamService.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using AutoMapper; From a7fbf760b23c3021b36db66a5aadec519f23a13a Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 20 Feb 2025 16:08:52 -0500 Subject: [PATCH 5/7] WIP #290 --- .../Practice/SearchPracticeChallengesTests.cs | 1 + .../Common/Services/PagingService.cs | 2 +- .../Features/Practice/PracticeController.cs | 11 ++++- .../Features/Practice/PracticeModels.cs | 11 +++++ .../Features/Practice/PracticeService.cs | 33 ++++++++++++++ .../GetUserPracticeHistory.cs | 23 ++++++++++ .../SearchPracticeChallenges.cs | 43 +++++++++++++++++-- .../SearchPracticeChallengesModels.cs | 12 ++++++ 8 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeHistory/GetUserPracticeHistory.cs diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/SearchPracticeChallengesTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/SearchPracticeChallengesTests.cs index e08b000f..db29e024 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/SearchPracticeChallengesTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/SearchPracticeChallengesTests.cs @@ -79,6 +79,7 @@ private SearchPracticeChallengesHandler GetSutWithResults(IFixture fixture, para var sut = new SearchPracticeChallengesHandler ( + A.Fake(), A.Fake(), A.Fake(), A.Fake(), diff --git a/src/Gameboard.Api/Common/Services/PagingService.cs b/src/Gameboard.Api/Common/Services/PagingService.cs index 0d87dc04..a4148c4b 100644 --- a/src/Gameboard.Api/Common/Services/PagingService.cs +++ b/src/Gameboard.Api/Common/Services/PagingService.cs @@ -30,7 +30,7 @@ public interface IPagingService internal class PagingService : IPagingService { - private static readonly int DEFAULT_PAGE_SIZE = 20; + private static readonly int DEFAULT_PAGE_SIZE = 25; public PagedEnumerable Page(IEnumerable items, PagingArgs pagingArgs = null) { diff --git a/src/Gameboard.Api/Features/Practice/PracticeController.cs b/src/Gameboard.Api/Features/Practice/PracticeController.cs index 6b2242bd..0c4d37f9 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeController.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeController.cs @@ -1,3 +1,4 @@ +using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Common.Services; using MediatR; @@ -18,11 +19,12 @@ public class PracticeController(IActingUserService actingUserService, IMediator /// Search challenges within games that have been set to Practice mode. /// /// + /// Whether or not the challenge has ever been completed by the current user (in practice mode). /// [HttpGet] [AllowAnonymous] - public Task Browse([FromQuery] SearchFilter model) - => _mediator.Send(new SearchPracticeChallengesQuery(model)); + public Task Browse([FromQuery] SearchFilter model, [FromQuery] bool? isCompleted = null) + => _mediator.Send(new SearchPracticeChallengesQuery(model, isCompleted)); [HttpGet("session")] public Task GetPracticeSession() @@ -34,6 +36,11 @@ public Task GetPracticeSession() public Task GetSettings() => _mediator.Send(new GetPracticeModeSettingsQuery(_actingUserService.Get())); + [HttpGet] + [Route("user/{userId}/history")] + public Task GetUserPracticeHistory([FromRoute] string userId, CancellationToken cancellationToken) + => _mediator.Send(new GetUserPracticeHistoryQuery(userId), cancellationToken); + [HttpPut] [Route("settings")] public Task UpdateSettings([FromBody] PracticeModeSettingsApiModel settings) diff --git a/src/Gameboard.Api/Features/Practice/PracticeModels.cs b/src/Gameboard.Api/Features/Practice/PracticeModels.cs index c9ed8b99..5a42b9c8 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeModels.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeModels.cs @@ -30,3 +30,14 @@ public sealed class PracticeModeSettingsApiModel public int? MaxPracticeSessionLengthMinutes { get; set; } public required IEnumerable SuggestedSearches { get; set; } } + +public sealed class UserPracticeHistoryChallenge +{ + public required string ChallengeId { get; set; } + public required string ChallengeName { get; set; } + public required string ChallengeSpecId { get; set; } + public required int AttemptCount { get; set; } + public required DateTimeOffset? BestAttemptDate { get; set; } + public required double? BestAttemptScore { get; set; } + public required bool IsComplete { get; set; } +} diff --git a/src/Gameboard.Api/Features/Practice/PracticeService.cs b/src/Gameboard.Api/Features/Practice/PracticeService.cs index 440d395e..e82bcaa7 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeService.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeService.cs @@ -19,6 +19,7 @@ public interface IPracticeService Task GetExtendedSessionEnd(DateTimeOffset currentSessionBegin, DateTimeOffset currentSessionEnd, CancellationToken cancellationToken); Task GetSettings(CancellationToken cancellationToken); Task GetUserActivePracticeSession(string userId, CancellationToken cancellationToken); + Task GetUserPracticeHistory(string userId, CancellationToken cancellationToken); Task> GetVisibleChallengeTags(CancellationToken cancellationToken); Task> GetVisibleChallengeTags(IEnumerable requestedTags, CancellationToken cancellationToken); IEnumerable UnescapeSuggestedSearches(string input); @@ -100,7 +101,9 @@ public async Task GetCanDeployChallenge(string u { var activeSessionUsers = await GetActiveSessionUsers(); if (activeSessionUsers.Count() >= settings.MaxConcurrentPracticeSessions.Value && !activeSessionUsers.Contains(userId)) + { return CanPlayPracticeChallengeResult.TooManyActivePracticeSessions; + } } return CanPlayPracticeChallengeResult.Yes; @@ -111,6 +114,36 @@ public async Task GetCanDeployChallenge(string u .Where(p => p.UserId == userId) .FirstOrDefaultAsync(cancellationToken); + public async Task GetUserPracticeHistory(string userId, CancellationToken cancellationToken) + { + // restrict to living specs #317 + var specs = await _store + .WithNoTracking() + .Where(s => s.Game.PlayerMode == PlayerMode.Practice || s.Game.Challenges.Any(c => c.PlayerMode == PlayerMode.Practice)) + .Select(s => s.Id) + .ToArrayAsync(cancellationToken); + + return await _store + .WithNoTracking() + .Where(c => c.Player.UserId == userId) + .Where(c => c.PlayerMode == PlayerMode.Practice) + .Where(c => specs.Contains(c.SpecId)) + .Where(c => c.Score > 0) + .GroupBy(c => new { c.Name, c.SpecId }) + .Select(gr => new UserPracticeHistoryChallenge + { + ChallengeName = gr.Key.Name, + ChallengeSpecId = gr.Key.SpecId, + AttemptCount = gr.Count(), + BestAttemptDate = gr.OrderByDescending(c => c.Score).First().StartTime, + BestAttemptScore = gr.OrderByDescending(c => c.Score).First().Score, + ChallengeId = gr.OrderByDescending(c => c.Score).First().Id, + IsComplete = gr.OrderByDescending(c => c.Score).First().Score >= gr.OrderByDescending(c => c.Score).First().Points + + }) + .ToArrayAsync(cancellationToken); + } + public async Task GetSettings(CancellationToken cancellationToken) { var settings = await _store.FirstOrDefaultAsync(cancellationToken); diff --git a/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeHistory/GetUserPracticeHistory.cs b/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeHistory/GetUserPracticeHistory.cs new file mode 100644 index 00000000..07c8038d --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeHistory/GetUserPracticeHistory.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Features.Users; +using Gameboard.Api.Structure.MediatR; +using MediatR; + +namespace Gameboard.Api.Features.Practice; + +public record GetUserPracticeHistoryQuery(string UserId) : IRequest; + +internal sealed class GetUserPracticeHistoryHandler(IPracticeService practice, IValidatorService validator) : IRequestHandler +{ + public async Task Handle(GetUserPracticeHistoryQuery request, CancellationToken cancellationToken) + { + await validator.Auth(c => + c.RequireOneOf(PermissionKey.Admin_View) + .UnlessUserIdIn(request.UserId) + ) + .Validate(cancellationToken); + + return await practice.GetUserPracticeHistory(request.UserId, cancellationToken); + } +} diff --git a/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs index c0e2ec89..d2e2ec07 100644 --- a/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs +++ b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -12,10 +13,11 @@ namespace Gameboard.Api.Features.Practice; -public record SearchPracticeChallengesQuery(SearchFilter Filter) : IRequest; +public record SearchPracticeChallengesQuery(SearchFilter Filter, bool? IsCompleted) : IRequest; internal class SearchPracticeChallengesHandler ( + IActingUserService actingUser, IChallengeDocsService challengeDocsService, IPagingService pagingService, IUserRolePermissionsService permissionsService, @@ -24,6 +26,7 @@ internal class SearchPracticeChallengesHandler IStore store ) : IRequestHandler { + private readonly IActingUserService _actingUser = actingUser; private readonly IChallengeDocsService _challengeDocsService = challengeDocsService; private readonly IPagingService _pagingService = pagingService; private readonly IUserRolePermissionsService _permissionsService = permissionsService; @@ -35,7 +38,8 @@ public async Task Handle(SearchPracticeChallenge { // load settings - we need these to make decisions about tag-based matches var settings = await _practiceService.GetSettings(cancellationToken); - var sluggedSuggestedSearches = settings.SuggestedSearches.Select(search => _slugger.Get(search)); + var sluggedSuggestedSearches = settings.SuggestedSearches.Select(_slugger.Get); + var hasGlobalPracticeCertificate = settings.CertificateTemplateId.IsNotEmpty(); var query = await BuildQuery(request.Filter.Term, sluggedSuggestedSearches); var results = await query @@ -46,7 +50,9 @@ public async Task Handle(SearchPracticeChallenge Description = s.Description, Text = s.Text, AverageDeploySeconds = s.AverageDeploySeconds, + HasCertificateTemplate = hasGlobalPracticeCertificate || s.Game.PracticeCertificateTemplateId != null, IsHidden = s.IsHidden, + ScoreMaxPossible = s.Points, SolutionGuideUrl = s.SolutionGuideUrl, Tags = ChallengeSpecMapper.StringTagsToEnumerableStringTags(s.Tags), Game = new PracticeChallengeViewGame @@ -59,6 +65,15 @@ public async Task Handle(SearchPracticeChallenge }) .ToArrayAsync(cancellationToken); + // load the user history so we can reflect their progress on all challenges + var userHistory = Array.Empty(); + var actingUserId = _actingUser.Get()?.Id; + + if (actingUserId.IsNotEmpty()) + { + userHistory = await _practiceService.GetUserPracticeHistory(actingUserId, cancellationToken); + } + foreach (var result in results) { // hide tags which aren't in the "suggested searches" configured in the practice area @@ -80,6 +95,23 @@ public async Task Handle(SearchPracticeChallenge PageSize = pageSize }); + // append historical data + if (userHistory.Length != 0) + { + foreach (var challenge in pagedResults.Items) + { + var challengeHistory = userHistory.FirstOrDefault(h => h.ChallengeSpecId == challenge.Id); + + challenge.UserBestAttempt = new PracticeChallengeViewUserHistory + { + AttemptCount = challengeHistory?.AttemptCount ?? 0, + BestAttemptDate = challengeHistory?.BestAttemptDate ?? default, + BestAttemptScore = challengeHistory?.BestAttemptScore ?? default, + IsComplete = challengeHistory?.IsComplete ?? false + }; + } + } + return new SearchPracticeChallengesResult { Results = pagedResults }; } @@ -110,7 +142,12 @@ public async Task Handle(SearchPracticeChallenge { q = q.Where(s => s.TextSearchVector.Matches(filterTerm) || s.Game.TextSearchVector.Matches(filterTerm)); q = q.OrderByDescending(s => s.TextSearchVector.Rank(EF.Functions.PlainToTsQuery(filterTerm))) - .ThenByDescending(s => s.Game.TextSearchVector.Rank(EF.Functions.PlainToTsQuery(filterTerm))); + .ThenByDescending(s => s.Game.TextSearchVector.Rank(EF.Functions.PlainToTsQuery(filterTerm))) + .ThenBy(s => s.Name); + } + else + { + q = q.OrderBy(s => s.Name); } return q; diff --git a/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallengesModels.cs b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallengesModels.cs index 0007c035..533a4081 100644 --- a/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallengesModels.cs +++ b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallengesModels.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace Gameboard.Api.Features.Practice; @@ -15,9 +16,12 @@ public sealed class PracticeChallengeView public required string Text { get; set; } public required PracticeChallengeViewGame Game { get; set; } public required int AverageDeploySeconds { get; set; } + public required bool HasCertificateTemplate { get; set; } public required bool IsHidden { get; set; } + public required double ScoreMaxPossible { get; set; } public required string SolutionGuideUrl { get; set; } public required IEnumerable Tags { get; set; } + public PracticeChallengeViewUserHistory UserBestAttempt { get; set; } } public sealed class PracticeChallengeViewGame @@ -27,3 +31,11 @@ public sealed class PracticeChallengeViewGame public required string Logo { get; set; } public required bool IsHidden { get; set; } } + +public sealed class PracticeChallengeViewUserHistory +{ + public required int AttemptCount { get; set; } + public required DateTimeOffset? BestAttemptDate { get; set; } + public required double? BestAttemptScore { get; set; } + public required bool IsComplete { get; set; } +} From dc829a5cc1369d587beefb00f66488bfc31bc89f Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 24 Feb 2025 17:34:25 -0500 Subject: [PATCH 6/7] WIP practice summary --- ...BuildPracticeChallengesQueryBase_Tests.cs} | 22 ++-- .../Challenge/Services/ChallengeService.cs | 11 +- .../Features/Practice/PracticeController.cs | 6 + .../Features/Practice/PracticeService.cs | 54 ++++++++- .../GetUserPracticeSummary.cs | 108 ++++++++++++++++++ .../GetUserPracticeSummaryModels.cs | 21 ++++ .../SearchPracticeChallenges.cs | 41 +------ .../PracticeMode/PracticeModeReportService.cs | 4 +- .../Sponsor/Handlers/DeleteSponsor.cs | 4 +- 9 files changed, 206 insertions(+), 65 deletions(-) rename src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/{SearchPracticeChallengesTests.cs => PracticeService_BuildPracticeChallengesQueryBase_Tests.cs} (80%) create mode 100644 src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummary.cs create mode 100644 src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummaryModels.cs diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/SearchPracticeChallengesTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/PracticeService_BuildPracticeChallengesQueryBase_Tests.cs similarity index 80% rename from src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/SearchPracticeChallengesTests.cs rename to src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/PracticeService_BuildPracticeChallengesQueryBase_Tests.cs index db29e024..adff4d61 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/SearchPracticeChallengesTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Practice/PracticeService_BuildPracticeChallengesQueryBase_Tests.cs @@ -1,14 +1,13 @@ -using Gameboard.Api.Common; +using AutoMapper; using Gameboard.Api.Common.Services; using Gameboard.Api.Data; -using Gameboard.Api.Features.Challenges; using Gameboard.Api.Features.Practice; using Gameboard.Api.Features.Users; using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Tests.Unit; -public class SearchPracticeChallengesTests +public class PracticeService_BuildPracticeChallengesQueryBase_Tests { [Theory, GameboardAutoData] public async Task SearchPracticeChallenges_WithDisabled_ReturnsEmpty(IFixture fixture) @@ -32,7 +31,7 @@ public async Task SearchPracticeChallenges_WithDisabled_ReturnsEmpty(IFixture fi var sut = GetSutWithResults(fixture, disabledSpec); // when a query for all challenges is issued - var query = await sut.BuildQuery(string.Empty, []); + var query = await sut.GetPracticeChallengesQueryBase(string.Empty); var result = await query.ToArrayAsync(CancellationToken.None); // then we expect no results @@ -61,14 +60,14 @@ public async Task SearchPracticeChallenges_WithEnabled_Returns(IFixture fixture) var sut = GetSutWithResults(fixture, enabledSpec); // when a query for all challenges is issued - var query = await sut.BuildQuery(string.Empty, []); + var query = await sut.GetPracticeChallengesQueryBase(string.Empty); var result = await query.ToArrayAsync(CancellationToken.None); // then we expect one result result.Length.ShouldBe(1); } - private SearchPracticeChallengesHandler GetSutWithResults(IFixture fixture, params Data.ChallengeSpec[] specs) + private PracticeService GetSutWithResults(IFixture fixture, params Data.ChallengeSpec[] specs) { var queryResults = specs.BuildMock(); @@ -77,17 +76,14 @@ private SearchPracticeChallengesHandler GetSutWithResults(IFixture fixture, para .WithAnyArguments() .Returns(queryResults); - var sut = new SearchPracticeChallengesHandler + return new PracticeService ( - A.Fake(), - A.Fake(), - A.Fake(), + A.Fake(), + A.Fake(), + A.Fake(), A.Fake(), - A.Fake(), A.Fake(), store ); - - return sut; } } diff --git a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs index 3387b436..496ce1a2 100644 --- a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs +++ b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs @@ -92,17 +92,18 @@ public int GetDeployingChallengeCount(string teamId) return entry.Specs.Count; } - public IEnumerable GetTags(Data.ChallengeSpec spec) + public string[] GetTags(string tagString) { - if (spec.Tags.IsEmpty()) + if (tagString.IsEmpty()) return []; - return CommonRegexes + return [.. + CommonRegexes .WhitespaceGreedy - .Split(spec.Tags) + .Split(tagString) .Select(m => m.Trim().ToLower()) .Where(m => m.IsNotEmpty()) - .ToArray(); + ]; } public async Task Create(NewChallenge model, string actorId, CancellationToken cancellationToken) diff --git a/src/Gameboard.Api/Features/Practice/PracticeController.cs b/src/Gameboard.Api/Features/Practice/PracticeController.cs index 0c4d37f9..50ab6501 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeController.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeController.cs @@ -1,6 +1,7 @@ using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Common.Services; +using Gameboard.Api.Features.Practice.Requests; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -41,6 +42,11 @@ public Task GetSettings() public Task GetUserPracticeHistory([FromRoute] string userId, CancellationToken cancellationToken) => _mediator.Send(new GetUserPracticeHistoryQuery(userId), cancellationToken); + [HttpGet] + [Route("user/{userId}/summary")] + public Task GetUserPracticeSummary([FromRoute] string userId, CancellationToken cancellationToken) + => _mediator.Send(new GetUserPracticeSummaryRequest(userId), cancellationToken); + [HttpPut] [Route("settings")] public Task UpdateSettings([FromBody] PracticeModeSettingsApiModel settings) diff --git a/src/Gameboard.Api/Features/Practice/PracticeService.cs b/src/Gameboard.Api/Features/Practice/PracticeService.cs index e82bcaa7..fd1e8144 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeService.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeService.cs @@ -6,6 +6,7 @@ using AutoMapper; using Gameboard.Api.Common.Services; using Gameboard.Api.Data; +using Gameboard.Api.Features.Users; using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Features.Practice; @@ -17,8 +18,17 @@ public interface IPracticeService // are unavailable when requested Task GetCanDeployChallenge(string userId, string challengeSpecId, CancellationToken cancellationToken); Task GetExtendedSessionEnd(DateTimeOffset currentSessionBegin, DateTimeOffset currentSessionEnd, CancellationToken cancellationToken); + Task> GetPracticeChallengesQueryBase(string filterTerm); Task GetSettings(CancellationToken cancellationToken); Task GetUserActivePracticeSession(string userId, CancellationToken cancellationToken); + + /// + /// Returns a summary of all of the requested user's practice activity (with one challengespec object per challenge they've ever attempted + /// at least once) + /// + /// + /// + /// Task GetUserPracticeHistory(string userId, CancellationToken cancellationToken); Task> GetVisibleChallengeTags(CancellationToken cancellationToken); Task> GetVisibleChallengeTags(IEnumerable requestedTags, CancellationToken cancellationToken); @@ -38,6 +48,7 @@ internal partial class PracticeService IGuidService guids, IMapper mapper, INowService now, + IUserRolePermissionsService permissionsService, ISlugService slugService, IStore store ) : IPracticeService @@ -45,6 +56,7 @@ IStore store private readonly IGuidService _guids = guids; private readonly IMapper _mapper = mapper; private readonly INowService _now = now; + private readonly IUserRolePermissionsService _permissions = permissionsService; private readonly ISlugService _slugService = slugService; private readonly IStore _store = store; @@ -109,6 +121,43 @@ public async Task GetCanDeployChallenge(string u return CanPlayPracticeChallengeResult.Yes; } + /// + /// Load the transformed query results from the database. + /// + /// + /// + public async Task> GetPracticeChallengesQueryBase(string filterTerm) + { + var canViewHidden = await _permissions.Can(PermissionKey.Games_ViewUnpublished); + + var q = _store + .WithNoTracking() + .Where(s => s.Game.PlayerMode == PlayerMode.Practice) + .Where(s => !s.Disabled); + + if (!canViewHidden) + { + // without the permission, neither spec nor the game can be hidden + q = q + .Where(s => !s.IsHidden) + .Where(s => s.Game.IsPublished); + } + + if (filterTerm.IsNotEmpty()) + { + q = q.Where(s => s.TextSearchVector.Matches(filterTerm) || s.Game.TextSearchVector.Matches(filterTerm)); + q = q.OrderByDescending(s => s.TextSearchVector.Rank(EF.Functions.PlainToTsQuery(filterTerm))) + .ThenByDescending(s => s.Game.TextSearchVector.Rank(EF.Functions.PlainToTsQuery(filterTerm))) + .ThenBy(s => s.Name); + } + else + { + q = q.OrderBy(s => s.Name); + } + + return q; + } + public Task GetUserActivePracticeSession(string userId, CancellationToken cancellationToken) => GetActivePracticeSessionsQueryBase() .Where(p => p.UserId == userId) @@ -129,17 +178,16 @@ public async Task GetUserPracticeHistory(string .Where(c => c.PlayerMode == PlayerMode.Practice) .Where(c => specs.Contains(c.SpecId)) .Where(c => c.Score > 0) - .GroupBy(c => new { c.Name, c.SpecId }) + .GroupBy(c => new { c.SpecId }) .Select(gr => new UserPracticeHistoryChallenge { - ChallengeName = gr.Key.Name, + ChallengeName = gr.OrderByDescending(c => c.Score).First().Name, ChallengeSpecId = gr.Key.SpecId, AttemptCount = gr.Count(), BestAttemptDate = gr.OrderByDescending(c => c.Score).First().StartTime, BestAttemptScore = gr.OrderByDescending(c => c.Score).First().Score, ChallengeId = gr.OrderByDescending(c => c.Score).First().Id, IsComplete = gr.OrderByDescending(c => c.Score).First().Score >= gr.OrderByDescending(c => c.Score).First().Points - }) .ToArrayAsync(cancellationToken); } diff --git a/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummary.cs b/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummary.cs new file mode 100644 index 00000000..6301aa2c --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummary.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Users; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Practice.Requests; + +public record GetUserPracticeSummaryRequest(string UserId) : IRequest; + +internal sealed class GetUserPracticeSummaryHandler +( + ChallengeService challengesService, + IPracticeService practiceService, + IStore store, + IValidatorService validatorService +) : IRequestHandler +{ + public async Task Handle(GetUserPracticeSummaryRequest request, CancellationToken cancellationToken) + { + await validatorService + .Auth(c => c.Require(PermissionKey.Admin_View).UnlessUserIdIn(request.UserId)) + .Validate(cancellationToken); + + var userHistory = await practiceService.GetUserPracticeHistory(request.UserId, cancellationToken); + var practiceSettings = await practiceService.GetSettings(cancellationToken); + var hasGlobalTemplate = practiceSettings.CertificateTemplateId.IsNotEmpty(); + + var allPracticeChallenges = await store + .WithNoTracking() + .Where(c => !c.IsHidden && !c.Disabled && c.Game.IsPublished) + .Where(c => c.Game.PlayerMode == PlayerMode.Practice) + .Select(c => new + { + c.Id, + c.Tags, + c.Points, + HasCertificate = practiceSettings.CertificateTemplateId + }) + .ToArrayAsync(cancellationToken); + + var allTags = allPracticeChallenges.SelectMany(c => challengesService.GetTags(c.Tags)).Distinct(); + var tagEngagement = new Dictionary(); + var countAttempted = 0; + var countCompleted = 0; + var totalPointsAvailable = 0d; + var totalPointsScored = 0d; + + foreach (var challenge in allPracticeChallenges) + { + // add the total available points + totalPointsAvailable += challenge.Points; + + // has the user tried this one? + var userChallengeHistory = userHistory.SingleOrDefault(c => c.ChallengeSpecId == challenge.Id); + + // add/increment all tag counts + foreach (var tag in challengesService.GetTags(challenge.Tags)) + { + if (!tagEngagement.TryGetValue(tag, out var engagement)) + { + engagement = new UserPracticeSummaryResponseTagEngagement + { + Tag = tag, + CountAttempted = 0, + CountAvailable = 0, + CountCompleted = 0, + PointsAvailable = 0, + PointsScored = 0 + }; + + tagEngagement.Add(tag, engagement); + } + + engagement.CountAvailable += 1; + engagement.PointsAvailable += challenge.Points; + + if (userChallengeHistory is not null) + { + engagement.CountAttempted += 1; + engagement.PointsScored += userChallengeHistory.BestAttemptScore ?? 0; + totalPointsScored += userChallengeHistory?.BestAttemptScore ?? 0; + + if (userChallengeHistory.IsComplete) + { + countCompleted += 1; + engagement.CountCompleted += 1; + } + } + } + } + + return new GetUserPracticeSummaryResponse + { + CountAttempted = countAttempted, + CountAvailable = allPracticeChallenges.Length, + CountCompleted = countCompleted, + PointsAvailable = totalPointsAvailable, + PointsScored = totalPointsScored, + Tags = [.. tagEngagement.OrderByDescending(kv => kv.Value.CountCompleted).Select(kv => kv.Value)] + }; + } +} diff --git a/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummaryModels.cs b/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummaryModels.cs new file mode 100644 index 00000000..eaf762b5 --- /dev/null +++ b/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummaryModels.cs @@ -0,0 +1,21 @@ +namespace Gameboard.Api.Features.Practice.Requests; + +public sealed class GetUserPracticeSummaryResponse +{ + public required int CountAttempted { get; set; } + public required int CountAvailable { get; set; } + public required int CountCompleted { get; set; } + public required double PointsAvailable { get; set; } + public required double PointsScored { get; set; } + public required UserPracticeSummaryResponseTagEngagement[] Tags { get; set; } +} + +public sealed class UserPracticeSummaryResponseTagEngagement +{ + public required string Tag { get; set; } + public required int CountAvailable { get; set; } + public required int CountAttempted { get; set; } + public required int CountCompleted { get; set; } + public required double PointsAvailable { get; set; } + public required double PointsScored { get; set; } +} diff --git a/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs index d2e2ec07..85672d5b 100644 --- a/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs +++ b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -41,7 +40,7 @@ public async Task Handle(SearchPracticeChallenge var sluggedSuggestedSearches = settings.SuggestedSearches.Select(_slugger.Get); var hasGlobalPracticeCertificate = settings.CertificateTemplateId.IsNotEmpty(); - var query = await BuildQuery(request.Filter.Term, sluggedSuggestedSearches); + var query = await _practiceService.GetPracticeChallengesQueryBase(request.Filter.Term); var results = await query .Select(s => new PracticeChallengeView { @@ -114,42 +113,4 @@ public async Task Handle(SearchPracticeChallenge return new SearchPracticeChallengesResult { Results = pagedResults }; } - - /// - /// Load the transformed query results from the database. (Broken out into its own function for unit testing.) - /// - /// - /// - /// - internal async Task> BuildQuery(string filterTerm, IEnumerable sluggedSuggestedSearches) - { - var canViewHidden = await _permissionsService.Can(PermissionKey.Games_ViewUnpublished); - - var q = _store - .WithNoTracking() - .Where(s => s.Game.PlayerMode == PlayerMode.Practice) - .Where(s => !s.Disabled); - - if (!canViewHidden) - { - // without the permission, neither spec nor the game can be hidden - q = q - .Where(s => !s.IsHidden) - .Where(s => s.Game.IsPublished); - } - - if (filterTerm.IsNotEmpty()) - { - q = q.Where(s => s.TextSearchVector.Matches(filterTerm) || s.Game.TextSearchVector.Matches(filterTerm)); - q = q.OrderByDescending(s => s.TextSearchVector.Rank(EF.Functions.PlainToTsQuery(filterTerm))) - .ThenByDescending(s => s.Game.TextSearchVector.Rank(EF.Functions.PlainToTsQuery(filterTerm))) - .ThenBy(s => s.Name); - } - else - { - q = q.OrderBy(s => s.Name); - } - - return q; - } } diff --git a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportService.cs index 698204b4..aa7a5d67 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportService.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportService.cs @@ -210,7 +210,7 @@ public async Task GetResultsByChallenge(PracticeModeR { var attempts = g.ToList(); var spec = ungroupedResults.Specs[g.Key]; - var specTags = _challengeService.GetTags(spec); + var specTags = _challengeService.GetTags(spec.Tags); var sponsorIdsPlayed = attempts.Select(a => a.Player.Sponsor.Id).Distinct(); var sponsorsPlayed = ungroupedResults @@ -350,7 +350,7 @@ public async Task GetResultsByUser(PracticeModeReport Track = specs[c.Key.SpecId].Game.Track }, MaxPossibleScore = specs[c.Key.SpecId].Points, - Tags = visibleTags.Intersect(_challengeService.GetTags(specs[c.Key.SpecId])) + Tags = visibleTags.Intersect(_challengeService.GetTags(specs[c.Key.SpecId].Tags)) }, Attempts = c .ToList() diff --git a/src/Gameboard.Api/Features/Sponsor/Handlers/DeleteSponsor.cs b/src/Gameboard.Api/Features/Sponsor/Handlers/DeleteSponsor.cs index 46bf63fd..cc664b45 100644 --- a/src/Gameboard.Api/Features/Sponsor/Handlers/DeleteSponsor.cs +++ b/src/Gameboard.Api/Features/Sponsor/Handlers/DeleteSponsor.cs @@ -44,11 +44,11 @@ await _validatorService .SingleAsync(s => s.Id == request.SponsorId, cancellationToken); // if this sponsor sponsors any players or users, we need to update them - if (entity.SponsoredPlayers.Any() || entity.SponsoredUsers.Any()) + if (entity.SponsoredPlayers.Count != 0 || entity.SponsoredUsers.Count != 0) { var defaultSponsor = await _sponsorService.GetDefaultSponsor(); - if (entity.SponsoredPlayers.Any()) + if (entity.SponsoredPlayers.Count != 0) { await _store .WithNoTracking() From 2c66760937c81835b29e7bb091997cc8a97af737 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 26 Feb 2025 15:00:29 -0500 Subject: [PATCH 7/7] VIP #613. --- .../Features/Practice/PracticeController.cs | 18 +++---- .../GetUserPracticeSummary.cs | 20 ++++++- .../GetUserPracticeSummaryModels.cs | 3 +- .../SearchPracticeChallenges.cs | 53 +++++++++++-------- .../SearchPracticeChallengesModels.cs | 9 ++++ .../Auth/ClaimsPrincipalExtensions.cs | 2 +- 6 files changed, 71 insertions(+), 34 deletions(-) diff --git a/src/Gameboard.Api/Features/Practice/PracticeController.cs b/src/Gameboard.Api/Features/Practice/PracticeController.cs index 50ab6501..64a0ab89 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeController.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeController.cs @@ -19,13 +19,13 @@ public class PracticeController(IActingUserService actingUserService, IMediator /// /// Search challenges within games that have been set to Practice mode. /// - /// - /// Whether or not the challenge has ever been completed by the current user (in practice mode). + /// + /// Whether or not the challenge has ever been attempted/completed by the current user (in practice mode). /// [HttpGet] [AllowAnonymous] - public Task Browse([FromQuery] SearchFilter model, [FromQuery] bool? isCompleted = null) - => _mediator.Send(new SearchPracticeChallengesQuery(model, isCompleted)); + public Task Search([FromQuery] SearchFilter filter, [FromQuery] SearchPracticeChallengesRequestUserProgress? userProgress = null) + => _mediator.Send(new SearchPracticeChallengesQuery(filter, userProgress)); [HttpGet("session")] public Task GetPracticeSession() @@ -37,6 +37,11 @@ public Task GetPracticeSession() public Task GetSettings() => _mediator.Send(new GetPracticeModeSettingsQuery(_actingUserService.Get())); + [HttpPut] + [Route("settings")] + public Task UpdateSettings([FromBody] PracticeModeSettingsApiModel settings) + => _mediator.Send(new UpdatePracticeModeSettingsCommand(settings, _actingUserService.Get())); + [HttpGet] [Route("user/{userId}/history")] public Task GetUserPracticeHistory([FromRoute] string userId, CancellationToken cancellationToken) @@ -46,9 +51,4 @@ public Task GetUserPracticeHistory([FromRoute] s [Route("user/{userId}/summary")] public Task GetUserPracticeSummary([FromRoute] string userId, CancellationToken cancellationToken) => _mediator.Send(new GetUserPracticeSummaryRequest(userId), cancellationToken); - - [HttpPut] - [Route("settings")] - public Task UpdateSettings([FromBody] PracticeModeSettingsApiModel settings) - => _mediator.Send(new UpdatePracticeModeSettingsCommand(settings, _actingUserService.Get())); } diff --git a/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummary.cs b/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummary.cs index 6301aa2c..8922cfec 100644 --- a/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummary.cs +++ b/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummary.cs @@ -44,7 +44,6 @@ await validatorService }) .ToArrayAsync(cancellationToken); - var allTags = allPracticeChallenges.SelectMany(c => challengesService.GetTags(c.Tags)).Distinct(); var tagEngagement = new Dictionary(); var countAttempted = 0; var countCompleted = 0; @@ -62,6 +61,11 @@ await validatorService // add/increment all tag counts foreach (var tag in challengesService.GetTags(challenge.Tags)) { + if (!practiceSettings.SuggestedSearches.Contains(tag)) + { + continue; + } + if (!tagEngagement.TryGetValue(tag, out var engagement)) { engagement = new UserPracticeSummaryResponseTagEngagement @@ -82,6 +86,7 @@ await validatorService if (userChallengeHistory is not null) { + countAttempted += 1; engagement.CountAttempted += 1; engagement.PointsScored += userChallengeHistory.BestAttemptScore ?? 0; totalPointsScored += userChallengeHistory?.BestAttemptScore ?? 0; @@ -102,7 +107,18 @@ await validatorService CountCompleted = countCompleted, PointsAvailable = totalPointsAvailable, PointsScored = totalPointsScored, - Tags = [.. tagEngagement.OrderByDescending(kv => kv.Value.CountCompleted).Select(kv => kv.Value)] + TagsPlayed = [.. + tagEngagement + .OrderByDescending(kv => kv.Value.CountCompleted) + .Select(kv => kv.Value) + .Where(kv => kv.CountAttempted > 0) + ], + TagsUnplayed = [.. + allPracticeChallenges + .SelectMany(c => challengesService.GetTags(c.Tags)) + .Where(t => practiceSettings.SuggestedSearches.Contains(t)) + .Where(t => !tagEngagement.ContainsKey(t) || tagEngagement[t].CountAttempted == 0) + ] }; } } diff --git a/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummaryModels.cs b/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummaryModels.cs index eaf762b5..a31d1c6e 100644 --- a/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummaryModels.cs +++ b/src/Gameboard.Api/Features/Practice/Requests/GetUserPracticeSummary/GetUserPracticeSummaryModels.cs @@ -7,7 +7,8 @@ public sealed class GetUserPracticeSummaryResponse public required int CountCompleted { get; set; } public required double PointsAvailable { get; set; } public required double PointsScored { get; set; } - public required UserPracticeSummaryResponseTagEngagement[] Tags { get; set; } + public required UserPracticeSummaryResponseTagEngagement[] TagsPlayed { get; set; } + public required string[] TagsUnplayed { get; set; } } public sealed class UserPracticeSummaryResponseTagEngagement diff --git a/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs index 85672d5b..1a184749 100644 --- a/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs +++ b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallenges.cs @@ -3,35 +3,29 @@ using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Common.Services; -using Gameboard.Api.Data; using Gameboard.Api.Features.Challenges; -using Gameboard.Api.Features.Users; using Gameboard.Api.Services; using MediatR; using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Features.Practice; -public record SearchPracticeChallengesQuery(SearchFilter Filter, bool? IsCompleted) : IRequest; +public record SearchPracticeChallengesQuery(SearchFilter Filter, SearchPracticeChallengesRequestUserProgress? UserProgress = default) : IRequest; internal class SearchPracticeChallengesHandler ( IActingUserService actingUser, IChallengeDocsService challengeDocsService, IPagingService pagingService, - IUserRolePermissionsService permissionsService, IPracticeService practiceService, - ISlugService slugger, - IStore store + ISlugService slugger ) : IRequestHandler { private readonly IActingUserService _actingUser = actingUser; private readonly IChallengeDocsService _challengeDocsService = challengeDocsService; private readonly IPagingService _pagingService = pagingService; - private readonly IUserRolePermissionsService _permissionsService = permissionsService; private readonly IPracticeService _practiceService = practiceService; private readonly ISlugService _slugger = slugger; - private readonly IStore _store = store; public async Task Handle(SearchPracticeChallengesQuery request, CancellationToken cancellationToken) { @@ -84,33 +78,50 @@ public async Task Handle(SearchPracticeChallenge result.Text = _challengeDocsService.ReplaceRelativeUris(result.Text); } - // resolve paging arguments - var pageSize = request.Filter.Take > 0 ? request.Filter.Take : 100; - var pageNumber = request.Filter.Skip / pageSize; - - var pagedResults = _pagingService.Page(results, new PagingArgs - { - PageNumber = pageNumber, - PageSize = pageSize - }); - // append historical data if (userHistory.Length != 0) { - foreach (var challenge in pagedResults.Items) + foreach (var challenge in results) { var challengeHistory = userHistory.FirstOrDefault(h => h.ChallengeSpecId == challenge.Id); challenge.UserBestAttempt = new PracticeChallengeViewUserHistory { AttemptCount = challengeHistory?.AttemptCount ?? 0, - BestAttemptDate = challengeHistory?.BestAttemptDate ?? default, - BestAttemptScore = challengeHistory?.BestAttemptScore ?? default, + BestAttemptDate = challengeHistory?.BestAttemptDate ?? default(DateTimeOffset?), + BestAttemptScore = challengeHistory?.BestAttemptScore ?? default(double?), IsComplete = challengeHistory?.IsComplete ?? false }; } } + // filter by complete if requested + if (request.UserProgress.HasValue) + { + switch (request.UserProgress.Value) + { + case SearchPracticeChallengesRequestUserProgress.NotAttempted: + results = [.. results.Where(c => c.UserBestAttempt?.BestAttemptDate == default)]; + break; + case SearchPracticeChallengesRequestUserProgress.Attempted: + results = [.. results.Where(c => c.UserBestAttempt?.BestAttemptDate != default && !c.UserBestAttempt.IsComplete)]; + break; + case SearchPracticeChallengesRequestUserProgress.Completed: + results = [.. results.Where(c => c.UserBestAttempt is not null && c.UserBestAttempt.IsComplete)]; + break; + } + } + + // resolve paging arguments + var pageSize = request.Filter.Take > 0 ? request.Filter.Take : 100; + var pageNumber = request.Filter.Skip / pageSize; + + var pagedResults = _pagingService.Page(results, new PagingArgs + { + PageNumber = pageNumber, + PageSize = pageSize + }); + return new SearchPracticeChallengesResult { Results = pagedResults }; } } diff --git a/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallengesModels.cs b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallengesModels.cs index 533a4081..fca57010 100644 --- a/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallengesModels.cs +++ b/src/Gameboard.Api/Features/Practice/Requests/SearchPracticeChallenges/SearchPracticeChallengesModels.cs @@ -3,6 +3,15 @@ namespace Gameboard.Api.Features.Practice; +public record SearchPracticeChallengesRequest(SearchFilter Term, SearchPracticeChallengesRequestUserProgress? Progress = default); + +public enum SearchPracticeChallengesRequestUserProgress +{ + NotAttempted, + Attempted, + Completed +} + public sealed class SearchPracticeChallengesResult { public required PagedEnumerable Results { get; set; } diff --git a/src/Gameboard.Api/Structure/Auth/ClaimsPrincipalExtensions.cs b/src/Gameboard.Api/Structure/Auth/ClaimsPrincipalExtensions.cs index 107699e1..735feae2 100644 --- a/src/Gameboard.Api/Structure/Auth/ClaimsPrincipalExtensions.cs +++ b/src/Gameboard.Api/Structure/Auth/ClaimsPrincipalExtensions.cs @@ -26,7 +26,7 @@ public static async Task ToActor(this ClaimsPrincipal principal, IUserRole Id = principal.Subject(), Name = principal.FindFirstValue(AppConstants.NameClaimName), ApprovedName = principal.FindFirstValue(AppConstants.ApprovedNameClaimName), - Role = finalRole.HasValue ? finalRole.Value : UserRoleKey.Member, + Role = finalRole ?? UserRoleKey.Member, RolePermissions = await userRolePermissionsService.GetPermissions(role), SponsorId = principal.FindFirstValue(AppConstants.SponsorClaimName) };