From 4772a4f2ad11f919d6f44068416303bc7f400e8d Mon Sep 17 00:00:00 2001 From: Ben Stein <115497763+sei-bstein@users.noreply.github.com> Date: Wed, 16 Oct 2024 13:57:01 -0400 Subject: [PATCH] v3.23.2 (#514) * Fixed an issue that caused user roles and login info to fail to appear on the user list page in admin. * Fixed an issue that could cause Gameboard to fail to align challenge end time with engine expiration. * WIP * Minor cleanup * Fixed a bug that could cause incorrect resolution of user team membership * Update topo package version * Bug fixes and new tags filter for the challenges report * Add unstarted teams/no launched challenge teams to enrollment report summary * Game center teams sort fix, score progress widget fix, cleanup * Fixed bugs: game level feedback, add tags to practice area report, hide invisible tags * Fix compile error --- src/Gameboard.Api/Common/CommonModels.cs | 1 + src/Gameboard.Api/Data/GameboardDbContext.cs | 4 +- .../Features/Feedback/FeedbackService.cs | 10 ++-- .../Features/Feedback/FeedbackValidator.cs | 24 +++----- .../Features/Practice/PracticeService.cs | 55 +++++++++++-------- .../ChallengesReportService.cs | 12 +++- .../PracticeMode/PracticeModeReportModels.cs | 1 + .../PracticeMode/PracticeModeReportService.cs | 24 +++++++- src/Gameboard.Api/Structure/Exceptions.cs | 2 +- 9 files changed, 81 insertions(+), 52 deletions(-) diff --git a/src/Gameboard.Api/Common/CommonModels.cs b/src/Gameboard.Api/Common/CommonModels.cs index c952b231..2a1140a2 100644 --- a/src/Gameboard.Api/Common/CommonModels.cs +++ b/src/Gameboard.Api/Common/CommonModels.cs @@ -1,4 +1,5 @@ using System; +using System.Text.RegularExpressions; namespace Gameboard.Api.Common; diff --git a/src/Gameboard.Api/Data/GameboardDbContext.cs b/src/Gameboard.Api/Data/GameboardDbContext.cs index 1349864e..9a3c3521 100644 --- a/src/Gameboard.Api/Data/GameboardDbContext.cs +++ b/src/Gameboard.Api/Data/GameboardDbContext.cs @@ -5,10 +5,8 @@ namespace Gameboard.Api.Data; -public class GameboardDbContext : DbContext +public class GameboardDbContext(DbContextOptions options) : DbContext(options) { - public GameboardDbContext(DbContextOptions options) : base(options) { } - protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); diff --git a/src/Gameboard.Api/Features/Feedback/FeedbackService.cs b/src/Gameboard.Api/Features/Feedback/FeedbackService.cs index 92443130..530208e6 100644 --- a/src/Gameboard.Api/Features/Feedback/FeedbackService.cs +++ b/src/Gameboard.Api/Features/Feedback/FeedbackService.cs @@ -141,7 +141,7 @@ public async Task Submit(FeedbackSubmission model, string actorId) { var valid = await FeedbackMatchesTemplate(model.Questions, model.GameId, model.ChallengeId); if (!valid) - throw new InvalideFeedbackFormat(); + throw new InvalidFeedbackFormat(); } if (entity is not null) @@ -293,7 +293,7 @@ private async Task FeedbackMatchesTemplate(QuestionSubmission[] feedback, var feedbackTemplate = GetTemplate(challengeId.IsEmpty(), game); if (feedbackTemplate.Length != feedback.Length) - throw new InvalideFeedbackFormat(); + throw new InvalidFeedbackFormat(); var templateMap = new Dictionary(); foreach (QuestionTemplate q in feedbackTemplate) { templateMap.Add(q.Id, q); } @@ -302,19 +302,19 @@ private async Task FeedbackMatchesTemplate(QuestionSubmission[] feedback, { var template = templateMap.GetValueOrDefault(q.Id, null); if (template == null) // user submitted id that isn't in game template - throw new InvalideFeedbackFormat(); + throw new InvalidFeedbackFormat(); if (template.Required && q.Answer.IsEmpty()) // requirement config is not met throw new MissingRequiredField(); if (q.Answer.IsEmpty()) // don't validate answer is null/empty, if not required continue; if (template.Type == "text" && q.Answer.Length > 2000) // universal character limit per text question - throw new InvalideFeedbackFormat(); + throw new InvalidFeedbackFormat(); if (template.Type == "likert") // because all likert options are ints, parse and check range with max config { int answerInt; bool isInt = Int32.TryParse(q.Answer, out answerInt); if (!isInt || answerInt < template.Min || answerInt > template.Max) // parsing failed or outside of range - throw new InvalideFeedbackFormat(); + throw new InvalidFeedbackFormat(); } } return true; diff --git a/src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs b/src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs index 86f41ed1..e422d7a8 100644 --- a/src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs +++ b/src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs @@ -2,11 +2,9 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using System; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Data; -using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Validators { @@ -28,29 +26,25 @@ private async Task _validate(FeedbackSubmission model) throw new ResourceNotFound(model.GameId); if (model.ChallengeId.IsEmpty() != model.ChallengeSpecId.IsEmpty()) // must specify both or neither - throw new InvalideFeedbackFormat(); + throw new InvalidFeedbackFormat(); - // if not blank, must exist for challenge and challenge spec - if (model.ChallengeSpecId.IsEmpty()) - throw new ArgumentException("ChallengeSpecId is required"); - - if (!await _store.AnyAsync(s => s.Id == model.ChallengeSpecId, CancellationToken.None)) - throw new ResourceNotFound(model.ChallengeSpecId); - - if (!await _store.AnyAsync(c => c.Id == model.ChallengeId, CancellationToken.None)) + if (model.ChallengeId.IsNotEmpty() && !await _store.AnyAsync(c => c.Id == model.ChallengeId, CancellationToken.None)) + { throw new ResourceNotFound(model.ChallengeSpecId); + } - // if specified, this is a challenge-specific feedback response, so validate challenge/spec/game match - if (model.ChallengeSpecId.NotEmpty()) + if (model.ChallengeSpecId.IsNotEmpty()) { + if (!await _store.AnyAsync(s => s.Id == model.ChallengeSpecId, CancellationToken.None)) + throw new ResourceNotFound(model.ChallengeSpecId); + + // if specified, this is a challenge-specific feedback response, so validate challenge/spec/game match if (!await _store.AnyAsync(s => s.Id == model.ChallengeSpecId && s.GameId == model.GameId, CancellationToken.None)) throw new ActionForbidden(); if (!await _store.AnyAsync(c => c.Id == model.ChallengeId && c.SpecId == model.ChallengeSpecId, CancellationToken.None)) throw new ActionForbidden(); } - - await Task.CompletedTask; } } } diff --git a/src/Gameboard.Api/Features/Practice/PracticeService.cs b/src/Gameboard.Api/Features/Practice/PracticeService.cs index b34c8b0a..f049bb40 100644 --- a/src/Gameboard.Api/Features/Practice/PracticeService.cs +++ b/src/Gameboard.Api/Features/Practice/PracticeService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using AutoMapper; @@ -19,6 +20,8 @@ public interface IPracticeService Task GetExtendedSessionEnd(DateTimeOffset currentSessionBegin, DateTimeOffset currentSessionEnd, CancellationToken cancellationToken); Task GetSettings(CancellationToken cancellationToken); Task GetUserActivePracticeSession(string userId, CancellationToken cancellationToken); + Task> GetVisibleChallengeTags(CancellationToken cancellationToken); + Task> GetVisibleChallengeTags(IEnumerable requestedTags, CancellationToken cancellationToken); IEnumerable UnescapeSuggestedSearches(string input); } @@ -29,38 +32,34 @@ public enum CanPlayPracticeChallengeResult Yes } -internal class PracticeService : IPracticeService +internal partial class PracticeService +( + IMapper mapper, + INowService now, + ISlugService slugService, + IStore store +) : IPracticeService { - private readonly IMapper _mapper; - private readonly INowService _now; - private readonly IStore _store; - - public PracticeService - ( - IMapper mapper, - INowService now, - IStore store - ) - { - _mapper = mapper; - _now = now; - _store = store; - } + private readonly IMapper _mapper = mapper; + private readonly INowService _now = now; + private readonly ISlugService _slugService = slugService; + private readonly IStore _store = store; // To avoid needing a table that literally just displays a list of strings, we store the list of suggested searches as a // newline-delimited string in the PracticeModeSettings table (which has only one record). public string EscapeSuggestedSearches(IEnumerable input) - => string.Join(Environment.NewLine, input.Select(search => search.Trim())); + => string.Join(Environment.NewLine, input.Select(search => _slugService.Get(search.Trim()))); // same deal here - split on newline public IEnumerable UnescapeSuggestedSearches(string input) { if (input.IsEmpty()) - return Array.Empty(); + return []; - return input - .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) - .Select(search => search.Trim()) + return Regex + .Split(input, @"/s+", RegexOptions.Multiline) + .Select(m => m.Trim().ToLower()) + .Where(m => m.IsNotEmpty()) .ToArray(); } @@ -121,7 +120,7 @@ public async Task GetSettings(CancellationToken ca CertificateHtmlTemplate = null, DefaultPracticeSessionLengthMinutes = 60, IntroTextMarkdown = null, - SuggestedSearches = Array.Empty() + SuggestedSearches = [] }; } @@ -131,6 +130,18 @@ public async Task GetSettings(CancellationToken ca return apiModel; } + public async Task> GetVisibleChallengeTags(CancellationToken cancellationToken) + { + var settings = await GetSettings(cancellationToken); + return settings.SuggestedSearches; + } + + public async Task> GetVisibleChallengeTags(IEnumerable requestedTags, CancellationToken cancellationToken) + { + var settings = await GetSettings(cancellationToken); + return requestedTags.Select(t => t.ToLower()).Intersect(settings.SuggestedSearches).ToArray(); + } + private async Task> GetActiveSessionUsers() => await GetActivePracticeSessionsQueryBase() .Select(p => p.UserId) diff --git a/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportService.cs index b6d10bc9..9c22b432 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportService.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/ChallengesReport/ChallengesReportService.cs @@ -6,8 +6,8 @@ using System.Threading.Tasks; using Gameboard.Api.Data; using Gameboard.Api.Features.Challenges; +using Gameboard.Api.Features.Practice; using Microsoft.EntityFrameworkCore; -using ServiceStack; namespace Gameboard.Api.Features.Reports; @@ -17,8 +17,9 @@ public interface IChallengesReportService ChallengesReportStatSummary GetStatSummary(IEnumerable records); } -internal class ChallengesReportService(IReportsService reportsService, IStore store) : IChallengesReportService +internal class ChallengesReportService(IPracticeService practiceService, IReportsService reportsService, IStore store) : IChallengesReportService { + private readonly IPracticeService _practiceService = practiceService; private readonly IReportsService _reportsService = reportsService; private readonly IStore _store = store; @@ -135,9 +136,14 @@ public async Task> GetRawResults(ChallengesR .Count() }, cancellationToken); + // we currently restrict tags we show on challenges (to avoid polluting the UI with internal tags). + // the non-awesome part of this is that we do it using the practice settings, because that's where we needed it first + var visibleTags = await _practiceService.GetVisibleChallengeTags(specs.SelectMany(s => s.Tags), cancellationToken); + var preSortResults = specs.Select(cs => { var aggregations = specAggregations.ContainsKey(cs.Id) ? specAggregations[cs.Id] : null; + var tags = (cs.Tags.IsEmpty() ? [] : cs.Tags).Where(visibleTags.Contains).ToArray(); return new ChallengesReportRecord { @@ -153,7 +159,7 @@ public async Task> GetRawResults(ChallengesR }, PlayerModeCurrent = cs.PlayerModeCurrent, Points = cs.Points, - Tags = cs.Tags.IsNotEmpty() ? cs.Tags : [], + Tags = tags, AvgCompleteSolveTimeMs = aggregations?.AvgCompleteSolveTimeMs, AvgScore = aggregations?.AvgScore, DeployCompetitiveCount = aggregations is not null ? aggregations.DeployCompetitiveCount : 0, diff --git a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportModels.cs b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportModels.cs index 635e21f1..1f6edfb6 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportModels.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportModels.cs @@ -104,6 +104,7 @@ public sealed class PracticeModeByChallengeReportRecord : IPracticeModeReportRec public required double MaxPossibleScore { get; set; } public required double AvgScore { get; set; } public required string Description { get; set; } + public required IEnumerable Tags { get; set; } public required string Text { get; set; } public required IEnumerable SponsorsPlayed { get; set; } public required PracticeModeReportByChallengePerformance OverallPerformance { get; set; } diff --git a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportService.cs index 70ea2f9c..971f7029 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportService.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/PracticeModeReportService.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Data; using Gameboard.Api.Features.Games; +using Gameboard.Api.Features.Practice; using Microsoft.EntityFrameworkCore; +using ServiceStack; namespace Gameboard.Api.Features.Reports; @@ -19,13 +22,14 @@ public interface IPracticeModeReportService Task GetPlayerModePerformanceSummary(string userId, bool isPractice, CancellationToken cancellationToken); } -internal class PracticeModeReportService : IPracticeModeReportService +internal partial class PracticeModeReportService : IPracticeModeReportService { + private readonly IPracticeService _practiceService; private readonly IReportsService _reportsService; private readonly IStore _store; - public PracticeModeReportService(IReportsService reportsService, IStore store) - => (_reportsService, _store) = (reportsService, store); + public PracticeModeReportService(IPracticeService practiceService, IReportsService reportsService, IStore store) + => (_practiceService, _reportsService, _store) = (practiceService, reportsService, store); private sealed class PracticeModeReportUngroupedResults { @@ -189,6 +193,9 @@ public async Task GetResultsByChallenge(PracticeModeR // the "false" argument here excludes competitive records (this grouping only looks at practice challenges) var ungroupedResults = await BuildUngroupedResults(parameters, false, cancellationToken); + // get tags that we want to display + var visibleTags = await _practiceService.GetVisibleChallengeTags(cancellationToken); + var records = ungroupedResults .Challenges .GroupBy(c => c.SpecId) @@ -212,6 +219,16 @@ public async Task GetResultsByChallenge(PracticeModeR Performance = BuildChallengePerformance(attempts, s) }); + var tags = Array.Empty(); + if (spec.Tags.IsNotEmpty()) + { + var specTags = Regex + .Split(spec.Tags, @"\s+", RegexOptions.Multiline) + .Select(m => m.Trim().ToLower()) + .ToArray(); + tags = visibleTags.Intersect(specTags).ToArray(); + } + return new PracticeModeByChallengeReportRecord { Id = spec.Id, @@ -228,6 +245,7 @@ public async Task GetResultsByChallenge(PracticeModeR MaxPossibleScore = spec.Points, AvgScore = attempts.Select(a => a.Score).Average(), Description = spec.Description, + Tags = tags, Text = spec.Text, SponsorsPlayed = sponsorsPlayed, OverallPerformance = performanceOverall, diff --git a/src/Gameboard.Api/Structure/Exceptions.cs b/src/Gameboard.Api/Structure/Exceptions.cs index 9aa06529..53c3dc89 100644 --- a/src/Gameboard.Api/Structure/Exceptions.cs +++ b/src/Gameboard.Api/Structure/Exceptions.cs @@ -72,7 +72,7 @@ public class InvalidConsoleAction : Exception { } public class AlreadyExists : Exception { } public class ChallengeLocked : Exception { } public class ChallengeStartPending : Exception { } - public class InvalideFeedbackFormat : Exception { } + public class InvalidFeedbackFormat : Exception { } public class PlayerIsntInGame : Exception { } public class InvalidPlayerMode : Exception { } public class MissingRequiredField : Exception { }